Skip to content

feat(milady-google): list managed calendars#472

Merged
lalalune merged 1 commit intoelizaOS:developfrom
dutchiono:feat/milady-google-calendar-list
Apr 27, 2026
Merged

feat(milady-google): list managed calendars#472
lalalune merged 1 commit intoelizaOS:developfrom
dutchiono:feat/milady-google-calendar-list

Conversation

@dutchiono
Copy link
Copy Markdown
Contributor

This fixes the hosted Google connector gap that is currently blocking secondary calendars in Milady LifeOps.

What changed

  • add GET /api/v1/milady/google/calendar/calendars
  • list managed Google calendars from users/me/calendarList
  • return normalized calendar summaries for app-lifeops multi-calendar feeds

Why

Hosted cloud currently serves milady/google/calendar/feed but returns 404 for milady/google/calendar/calendars.
That leaves hosted users stuck on primary and prevents secondary calendars like Quinn from showing up in LifeOps.

Validation

  • bun test --preload ./packages/tests/load-env.ts packages/tests/unit/milady-google-multi-account.test.ts packages/tests/unit/milady-google-calendar-calendars-route.test.ts
  • bun run check-types

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

@dutchiono is attempting to deploy a commit to the elizaOS Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 090864bf-2a33-4da9-87f3-872b68451264

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dutchiono
Copy link
Copy Markdown
Contributor Author

Hosted production is currently returning \200\ for \/api/v1/milady/google/calendar/feed?calendarId=primary...\ and \404\ for \/api/v1/milady/google/calendar/calendars?side=owner\ against the same connected account. This PR is the missing hosted route required for secondary calendars like Quinn to appear in LifeOps.

@lalalune lalalune merged commit a51fa7d into elizaOS:develop Apr 27, 2026
2 of 3 checks passed
lalalune added a commit that referenced this pull request Apr 27, 2026
* fix: steward URL + CSP for eliza cloud login

- Use NEXT_PUBLIC_STEWARD_API_URL (eliza.steward.fi) not api.steward.fi
- Add steward domains to Content-Security-Policy connect-src

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove duplicate StewardProvider from login section

The root layout already wraps everything in StewardAuthProvider.
Having a second StewardProvider in the login section caused context
conflicts and prevented provider discovery (no Google/Discord buttons).

Now uses the root provider's context directly.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: auth redirect in useEffect + loading state when authenticated

- router.replace must be in useEffect, not render phase
- Show spinner + 'Redirecting...' instead of empty card when already auth'd
- StewardLogin still shows for unauthenticated users

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat: steward session cookie bridge for server-side auth

- getCurrentUser() now checks steward-token cookie alongside privy-token
- POST /api/auth/steward-session: verifies steward JWT, sets httpOnly cookie
- DELETE /api/auth/steward-session: clears cookie on logout
- AuthTokenSync calls the session API when steward auth state changes
- JIT user sync from steward (mirrors Privy JIT sync pattern)

This bridges localStorage (client) → cookie (server) so dashboard
pages can authenticate steward users via requireAuth().

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: use local StewardProvider for login (fixes OAuth button visibility)

Root layout and login page may bundle @stwd/react separately in
Turbopack, creating different React contexts. This means the login
page's StewardLogin can't see the root provider's discovered providers.

Fix: use a local StewardProvider in the login section so provider
discovery happens in the same context as StewardLogin. Also wire
the session cookie bridge directly in onSuccess.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove require() hack that broke Turbopack build

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: custom login UI using @stwd/sdk directly (bypasses React context)

Turbopack bundles @stwd/react into separate chunks for the root layout
and login page, creating different React.createContext instances.
StewardLogin can never see providers from its wrapping StewardProvider.

Solution: skip @stwd/react entirely for the login form. Use @stwd/sdk's
StewardAuth class directly to:
- Fetch providers (google/discord/passkey/email)
- Handle passkey, email magic link, and OAuth sign-in
- Set session cookie via /api/auth/steward-session
- Redirect to dashboard

Custom UI matches Eliza Cloud's dark/orange design system.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: bypass Privy middleware for steward-session route

proxy.ts middleware catches ALL /api/* routes and requires a valid
Privy token. Our /api/auth/steward-session route validates its own
steward JWT, so it needs to be in the public bypass list.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: redirectUrl → redirectUri (StewardOAuthConfig type)

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: middleware reads steward-token cookie + replace emojis with icons

- proxy.ts middleware now checks steward-token cookie alongside privy-token
  for protected paths like /dashboard (was redirecting back to /login)
- Replaced emoji passkey (🔑) and email (✉️) with proper SVG icons

Co-authored-by: wakesync <shadow@shad0w.xyz>

* debug: add steward-debug endpoint to trace auth failure

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: pass elizacloud tenantId to StewardAuth + debug sync

Without tenantId, steward issues tokens with personal-{uuid} tenant
instead of elizacloud. Also enhanced debug endpoint to test JIT sync.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: use window.location.href instead of router.replace for redirect

Next.js router.replace may be blocked by layout-level auth guards.
Hard navigation ensures the cookie is sent with the new request.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: skip StewardProvider on /docs pages (nextra CSS conflict)

The @stwd/react StewardProvider wraps in a stwd-root div with CSS
custom properties that override nextra's dark theme, causing a black
screen on docs pages. Skip it on /docs routes.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: skip Privy verification for steward tokens + neutralize stwd-root CSS

1. proxy.ts: steward-token cookies are HS256 (not RS256 like Privy).
   When only steward-token is present, pass through middleware without
   Privy verification. getCurrentUser() handles HS256 verification.

2. globals.css: neutralize .stwd-root background/color so the wrapper
   div from @stwd/react doesn't override page themes (fixes docs blackout,
   works for all pages without pathname checks).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove stray brace from StewardProvider

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove stray brace in StewardProvider (rebase artifact)

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: skip StewardProvider entirely on docs/blog pages

CSS neutralization alone may not be sufficient. The stwd-root div's
JS initialization can still crash nextra hydration. Skip the entire
provider on /docs and /blog routes.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: upgrade @stwd/react to 0.6.5 + remove slop patches

@stwd/react@0.6.5 no longer wraps children in a stwd-root div.
Removed all workarounds:
- pathname check for /docs in StewardAuthProvider
- CSS !important override for .stwd-root in globals.css
- usePathname import

Co-authored-by: wakesync <shadow@shad0w.xyz>

* cloud: handle missing request context apis in tests

* feat(#55): Gateway — enrich POST body with platform metadata

* refactor(#55): align tests with live-test pattern, extract pure helpers

Replace heavy-mock test suites (mock.module, fake Redis, spyOn fetch)
with pure-function tests following the post-scrub testing convention.

Extract testable helpers: buildForwardBody(), resolveSource(),
resolveUserName(), buildConnectionMetadata() — tested without mocks.

Made-with: Cursor

* fix(#55): address PR review — PII, validation, metadata guard

- Remove chatId from all debug log payloads (PII for Twilio/WhatsApp)
- Add KNOWN_PLATFORMS allowlist guard on resolveSource() and
  buildConnectionMetadata() to reject unrecognized platformName values
- Only construct metadata object in routes.ts when at least one field
  is present (pass undefined for backward-compatible path)
- Add 4 new tests for allowlist validation edge cases

Made-with: Cursor

* fix(#55): address follow-up review — orphaned chatId, length guard, clean metadata

- chatId now requires a valid platformName to be stored in connection
  metadata (orphaned chatId without platform is useless for routing)
- senderName capped at 255 chars in resolveUserName() to prevent
  oversized values from reaching the database
- routes.ts builds metadata via conditional spread (no undefined values)
- Debug log gated behind metadata presence (no misleading log when
  all fields are absent)
- PII comments added to all log sites explaining senderName/chatId
  omission
- Documented the unofficial metadata extension on ensureConnection cast

Made-with: Cursor

* cloud: stabilize auth and browser test coverage

* fix(#55): runtime type guards, validated log payloads, chatId discard warning

- Add `typeof === "string"` guards on optional body fields in routes.ts
  to reject non-string values at runtime, not just compile time
- Move debug log from routes.ts into handleMessage and log the validated
  `source` (post-resolveSource) instead of raw `platformName` from the
  request body — prevents arbitrary strings from entering structured logs
- Gate the debug log behind `if (metadata)` so it only fires when
  platform context is actually present
- Add `logger.warn` in buildConnectionMetadata when chatId is discarded
  due to an unrecognized platformName — makes silent data loss visible
- Annotate KNOWN_PLATFORMS with a sync comment pointing to Platform type
  in gateway-webhook and SUPPORTED_PLATFORMS in the webhook config route
- Change KNOWN_PLATFORMS to ReadonlySet<string> for immutability

Made-with: Cursor

* fix(#55): chatId length cap, narrowed type cast, edge-case test coverage

- Add MAX_CHAT_ID_LENGTH (128) and truncate chatId in
  buildConnectionMetadata to match the senderName length-cap pattern
- Narrow the ensureConnection type cast to an intersection
  (Parameters<…>[0] & { metadata?: … }) so the compiler still validates
  the standard fields while allowing the unofficial metadata extension
- Add two new tests: valid platform with empty chatId (omitted from
  result), and chatId truncation at 128 chars
- Add guard comment on the metadata ternary in routes.ts explaining why
  an empty {} must not reach handleMessage

Made-with: Cursor

* fix(#55): warn on unrecognized platformName, add TODO for upstream cast

- resolveSource now logs logger.warn when a non-empty platformName is
  not in KNOWN_PLATFORMS, making misconfigured adapters observable
  without waiting for a chatId to be attached
- Replace descriptive comment on ensureConnection cast with an
  actionable TODO so the cleanup is discoverable when upstream adds
  the metadata field
- Remove unnecessary optional chain on metadata.chatId after
  validPlatform is confirmed (metadata cannot be nullish at that point)

Made-with: Cursor

* fix(#55): deduplicate warnings, single body cast, spy assertions in tests

- Remove duplicate warn from buildConnectionMetadata — resolveSource
  already warns on unrecognized platformName. buildConnectionMetadata
  now emits logger.debug when chatId is discarded (covers both
  no-platform and unrecognized-platform paths)
- Consolidate routes.ts to a single `body as Record<string, unknown>`
  cast with typeof guards on all five fields (userId, text, platformName,
  senderName, chatId)
- Add spyOn(logger) assertions in metadata-helpers.test.ts to verify
  the warn and debug log paths fire correctly
- Tag the ensureConnection TODO with (#55) for discoverability

Made-with: Cursor

* fix(#55): restore mocks after tests, deduplicate logs, tighten return type

- Add afterEach(() => mock.restore()) so logger spies don't leak across
  tests when Bun shares a worker process
- Suppress buildConnectionMetadata debug log when platformName is
  present-but-unrecognized (resolveSource already warns for that case);
  debug now only fires for chatId-without-any-platformName
- Tighten buildForwardBody return type from Record<string, string> to
  { userId: string; text: string } & Partial<ForwardMessageOptions>

Made-with: Cursor

* feat: add OpenRouter as fallback AI provider alongside Vercel Gateway

When Vercel AI Gateway is unavailable or out of credits, inference
requests automatically fall through to OpenRouter. Supports both
configuration-level fallback (OPENROUTER_API_KEY without gateway key)
and per-request failover on 402/429 errors for raw HTTP proxy routes.

- Add OpenRouterProvider raw HTTP class (chat/completions, embeddings, models)
- Add withProviderFallback() utility for automatic 402/429 retry
- Wire OpenRouter into getLanguageModel/getProviderForModel resolution chains
- Add free model mapping for fallback (gateway models → OpenRouter :free tier)
- Integrate OpenRouter model catalog with SWR caching
- Fix models/status and models/[...model] routes to work in OpenRouter-only mode
- Add OPENROUTER_API_KEY to env-validator AI feature check

* feat: dynamic per-org rate limits based on cumulative spend

Replace hardcoded rate limit presets with automatic tier-based limits
per organization. Tiers are computed from paid credit purchases (excluding
free/bonus credits) and cached in Redis (1h TTL).

Tiers: free (60rpm), paid >= $5 (120rpm), growth >= $100 (300rpm).
Enterprise overrides via org_rate_limit_overrides table + admin API.

* fix: address code review feedback on org rate limits

- Add tier cache invalidation on app purchases (not just regular purchases)
- Parallelize DB queries in recalculateOrgTier (credit sum + override lookup)
- Sort tier thresholds defensively before matching
- Add max(10000) cap on admin RPM override values
- Fix PATCH null handling: null clears a field, omitted = no change
- Remove unused notInArray import

* fix: address second review — UUID validation, static imports, shared counter comment

- Validate orgId as UUID in admin endpoint (400 instead of 500 on invalid)
- Replace dynamic import() with static import for EndpointType and getOrgRpmForEndpoint
- Add comment clarifying /responses shares "completions" counter intentionally

* test: add unit tests for org rate limit tier system

25 tests covering:
- Tier threshold matching (free/paid/growth boundaries)
- Override merging (partial, all-null, no override)
- Endpoint RPM mapping (completions/embeddings/standard/strict)
- Route wiring verification (source code parity checks)
- Config parity (test values match source constants)

* fix: address third review — Redis gate, upsert safety, index, sort

- Skip enforceOrgRateLimit when REDIS_RATE_LIMITING !== "true" (dev/staging)
- Upsert SET only includes explicitly provided fields (preserves existing)
- Add composite index (organization_id, type) on credit_transactions
- Sort tier thresholds once at module load instead of per call

* fix: address fourth review — docs, export, tests

- Document superadmin-only auth on admin endpoint
- Add comment on PATCH null/omit semantics
- Export OrgTierData type
- Add test: free credits excluded from tier computation
- Add test: null clears override field back to tier default

* fix: reject empty PATCH, add CHECK constraint on rpm columns

- Reject PATCH with no override fields (prevents misleading "custom" tier)
- Add DB CHECK constraint: rpm columns must be NULL or > 0

* fix: remove non-null assertions, handle cache write failure

- Replace organization_id! with defensive if-guard in completions and embeddings
- Wrap cache.set in try/catch so Redis failure doesn't 500 the request
- Add CHECK constraint documentation comment in schema file

* fix: all-null override keeps base tier name, validate org exists on PATCH

- tierName only set to "custom" when at least one RPM field is non-null
- Admin PATCH/DELETE returns 404 if org doesn't exist (instead of FK 500)
- Update tests to match new all-null override behavior

* cloud: fix lint formatting on model catalog routes

* cloud: fix post-merge lint cleanup

* cloud: fix gateway webhook test paths

* cloud: align gateway webhook bun version

* cloud: prefix gateway webhook test filters

* cloud: run current gateway webhook test files

* fix: register org_rate_limit_overrides migration in Drizzle journal

The migration was written manually in PR #452 but never registered via
`db:generate`. This replaces it with a Drizzle-generated migration
(0060_zippy_joshua_kane.sql) that includes the table, FK, CHECK
constraint, and composite index.

(cherry picked from commit 7436ffdc1e34b04fffaa145be4bfa9eab75ce718)

* cloud: renumber org rate limit journal migration

* cloud: add a canonical ai pricing catalog

* cloud: bill inference endpoints from live catalog

* cloud: expose live pricing refresh and summaries

* fix(typecheck): tighten gateway metadata helpers

* cloud: patch postinstall and api route discovery

Normalize published @elizaos/core bundles away from zod loose() at install time and teach public route discovery to detect re-exported HTTP handlers.

* fix: move steward passthrough after bearerToken declaration

bearerToken was referenced before its const declaration (temporal dead
zone), causing a ReferenceError that crashed the middleware and redirected
to /auth/error on every steward-authenticated request.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: patch pricing, privacy, and security issues in live-ai-pricing (#456)

* fix: implement Seedance 2.0 pricing parser and add models to catalog

Implement the seedance pricing parser that was throwing an unimplemented
error, crashing the entire pricing refresh cron when Seedance models
appeared in the catalog.

Parser extracts per-second pricing from fal.ai model pages:
- Seedance 2.0: $0.3034/second (720p)
- Seedance 2.0 Fast: $0.2419/second (720p)

Audio is included in the base price (no audio dimension needed).

Also adds both models to SUPPORTED_VIDEO_MODELS in the definitions file.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: document public-only blob access limitation

Vercel Blob only supports access: 'public' as of 2026-04. Added TODO
comment explaining the limitation and noting that a proper fix requires
an auth-gated proxy route to serve blob content with session validation.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: charge ~10% on failed video generation instead of full refund

Shaw changed failed video reconciliation from partial charge to full
refund (reconcile(0)), creating an abuse vector where users could
trigger failures intentionally at zero cost while fal.ai still charges
for the compute attempt.

Restore partial charge at ~10% of quoted cost for all failure paths:
- No video URL in response
- Blob upload failure
- General generation error

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add error handling to /api/v1/pricing/summary endpoint

Wrap each model cost lookup in try/catch so one failing fal endpoint
(or any third-party catalog) won't 500 the entire public unauthenticated
route.

Failed lookups are filtered out and partial results returned with a
warnings array. Categories with zero successful lookups are omitted
from the response entirely.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add is_public column to generations, filter explore gallery

listRandomPublicImages() was returning ALL completed images across all
users, leaking private generations in the explore/discover section.

Added is_public boolean column (default false) via migration 0065.
Updated the query to filter by is_public = true so only explicitly
opted-in content appears in the explore gallery. Includes a partial
index on is_public WHERE true for query performance.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: evict expired entries from third-partyCatalogCache to prevent unbounded growth

The module-level third-partyCatalogCache Map was never pruned. While in
practice only ~4 keys are used (gateway, openrouter, fal, elevenlabs),
expired entries were never removed. Add eviction of expired entries
before inserting new ones in getCachedExternalEntries.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: require authentication for image generation, remove anonymous fallback

Anonymous users could generate images for free by hitting the endpoint
without auth. The authenticateUser() function silently fell back to
creating anonymous users who bypassed all credit checks.

Now returns 401 if no valid session or API key is provided. Cleaned up
all anonymous-specific code paths (isAnonymous checks, anonymous
reservation creation for unauthenticated users).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add DB fallback when fal.ai HTML pricing parsers fail

The 8+ fal.ai pricing parsers scrape HTML with regexes. If fal changes
their page structure, the parsers throw and crash the entire fal catalog
refresh.

Now each model's parse is wrapped in try/catch. On failure, fall back to
last known active DB entries for that model. If no DB fallback exists,
log an error and return empty (other models still succeed).

This prevents one broken model page from taking down pricing for all
video models.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: move steward passthrough after bearerToken declaration

bearerToken was referenced before its const declaration (temporal dead
zone), causing a ReferenceError that crashed the middleware and redirected
to /auth/error on every steward-authenticated request.

Co-authored-by: wakesync <shadow@shad0w.xyz>

---------

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: dashboard layout accepts steward auth (cookie check)

The dashboard layout's client-side auth check only looked at Privy's
usePrivy().authenticated. Steward users have a steward-token cookie
but no Privy session, causing a redirect loop.

Now checks for steward-token cookie alongside Privy auth.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* test: seed affiliate billing pricing data

* fix(auth): recognize steward sessions in dashboard chrome

- treat steward auth as signed-in for user menu, sidebar CTA, and locked nav items
- fetch server profile/org balance for steward-backed sessions
- clear steward session on sign out

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): sweep generic ui flows for steward sessions

- add shared hybrid session auth hook for privy + steward
- update landing, invite, payment success, admin, chat, and settings surfaces
- keep generic session-aware UI from rendering logged-out states for steward users
- preserve privy-specific flows where bearer token login is still intentional

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(admin): remove stale wallets reference after useSessionAuth migration

Co-authored-by: wakesync <shadow@shad0w.xyz>

* ci: add workflow dispatch for PR approval

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): make steward auth hook safe outside StewardProvider

useStewardAuth() now returns fallback defaults when called outside
<StewardProvider>, preventing crash when STEWARD_AUTH_ENABLED is false
or the provider is conditionally unmounted. All direct @stwd/react
imports replaced with the safe wrapper from use-session-auth.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): stop AuthTokenSync from deleting cookies on initial mount

AuthTokenSync was calling DELETE /api/auth/steward-session on first
render because isAuthenticated starts as false before the provider's
useEffect reads localStorage. This wiped the steward-authed cookie
before the dashboard could see it.

Now only clears cookies on actual sign-out (wasAuthenticated ref
tracks whether we were ever authed in this session).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): stabilize auth prop to prevent StewardAuth recreation on every render

The `auth={{ baseUrl: apiUrl }}` inline object literal caused a new
reference on every render, which made @stwd/react's internal useMemo
re-create the StewardAuth instance every render. That thrashed the
authSession state and prevented isAuthenticated from settling to true
even when a valid JWT existed in localStorage.

Now memoizing the auth config object so the internal StewardAuth is
created once per apiUrl change.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): read steward session directly from localStorage

Bypass @stwd/react's internal auth state (which was not reliably
initializing from localStorage during hydration) by:

1. useSessionAuth now reads the steward JWT directly from localStorage
   in addition to the provider's isAuthenticated. If either says we're
   logged in, the UI treats the user as authenticated.

2. AuthTokenSync now syncs the localStorage token to the server cookie
   on mount regardless of what the provider reports, and only DELETEs
   the cookie on actual token removal (not on initial false state).

This makes the dashboard chrome and auth-gated UI robust to the
provider's hydration timing and any state-thrashing issues.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): migrate chrome components to useSessionAuth

sidebar-item, sidebar-bottom-panel, and user-menu were still using the
thin useStewardAuth wrapper which only checks the provider's internal
auth state. Switching them to useSessionAuth which reads localStorage
directly, so steward auth is detected even when @stwd/react's provider
is slow/failing to initialize.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): use server-side redirect flow for OAuth

The SDK's popup-based signInWithOAuth expects the server to redirect
back with code+state so the client can do its own PKCE exchange, but
steward does the full exchange server-side and redirects back with
token+refreshToken directly. That mismatch caused "OAuth state
mismatch, possible CSRF attack" errors.

Now: OAuth button triggers a full-page redirect to
${STEWARD_API_URL}/auth/oauth/:provider/authorize?redirect_uri=...
Steward handles everything and redirects back to /login?token=...
where we store tokens in localStorage and continue the normal flow.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): switch CLI login page to steward session

CLI login page was using usePrivy which doesn't see steward sessions.
Now uses useSessionAuth so both Privy and Steward users can authenticate
to generate CLI API keys. Sign In button routes through the normal
/login?returnTo=... flow which already supports steward passkey, email,
and OAuth.

The server-side /api/auth/cli-session/[sessionId]/complete endpoint
already uses requireAuthWithOrg -> getCurrentUser which reads both
steward-token and privy cookies, so no backend changes needed.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(auth): auto-refresh steward JWT before expiry

Adds a periodic check (every 60s) that refreshes the steward access
token when it has fewer than 120 seconds remaining. Uses a standalone
StewardAuth instance that calls refreshSession() (exchange refresh
token for new access token) and re-syncs the updated JWT to the
server cookie.

This prevents the 15-min JWT expiry from silently logging users out
and breaking server-side auth (requireAuthWithOrg).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(auth): display error states on login page for failed callbacks

When the steward email or oauth callback fails, the backend now
redirects to /login?error=...&reason=.... Surface those errors
as a friendly inline message on the login page, map reason codes
to human text, and clean the URL after display.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* docs(auth): triage remaining Privy callsites for migration

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(oauth): allow Milady-origin redirect URLs in generic callback

The generic OAuth callback's local isValidRedirectUrl was same-origin-only,
so Google OAuth flows initiated from Milady (desktop/web) landed on the
cloud dashboard instead of bouncing back to the originating app. Replace
the bespoke validator with the shared redirect-validation helpers plus a
new resolveOAuthSuccessRedirectUrl that accepts:

  - relative paths on the cloud base URL
  - absolute URLs on the cloud base URL
  - absolute URLs on allowlisted Milady origins (milady.ai, app.milady.ai,
    extras, plus wildcard loopback for desktop dev)

Also validate redirectUrl up front in the initiate route so bad inputs
fail with 400 instead of silently falling back on callback.

* fix: implement Seedance 2.0 pricing parser and add models to catalog

Implement the seedance pricing parser that was throwing an unimplemented
error, crashing the entire pricing refresh cron when Seedance models
appeared in the catalog.

Parser extracts per-second pricing from fal.ai model pages:
- Seedance 2.0: $0.3034/second (720p)
- Seedance 2.0 Fast: $0.2419/second (720p)

Audio is included in the base price (no audio dimension needed).

Also adds both models to SUPPORTED_VIDEO_MODELS in the definitions file.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: document public-only blob access limitation

Vercel Blob only supports access: 'public' as of 2026-04. Added TODO
comment explaining the limitation and noting that a proper fix requires
an auth-gated proxy route to serve blob content with session validation.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: charge ~10% on failed video generation instead of full refund

Shaw changed failed video reconciliation from partial charge to full
refund (reconcile(0)), creating an abuse vector where users could
trigger failures intentionally at zero cost while fal.ai still charges
for the compute attempt.

Restore partial charge at ~10% of quoted cost for all failure paths:
- No video URL in response
- Blob upload failure
- General generation error

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add error handling to /api/v1/pricing/summary endpoint

Wrap each model cost lookup in try/catch so one failing fal endpoint
(or any third-party catalog) won't 500 the entire public unauthenticated
route.

Failed lookups are filtered out and partial results returned with a
warnings array. Categories with zero successful lookups are omitted
from the response entirely.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add is_public column to generations, filter explore gallery

listRandomPublicImages() was returning ALL completed images across all
users, leaking private generations in the explore/discover section.

Added is_public boolean column (default false) via migration 0065.
Updated the query to filter by is_public = true so only explicitly
opted-in content appears in the explore gallery. Includes a partial
index on is_public WHERE true for query performance.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: evict expired entries from third-partyCatalogCache to prevent unbounded growth

The module-level third-partyCatalogCache Map was never pruned. While in
practice only ~4 keys are used (gateway, openrouter, fal, elevenlabs),
expired entries were never removed. Add eviction of expired entries
before inserting new ones in getCachedExternalEntries.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: require authentication for image generation, remove anonymous fallback

Anonymous users could generate images for free by hitting the endpoint
without auth. The authenticateUser() function silently fell back to
creating anonymous users who bypassed all credit checks.

Now returns 401 if no valid session or API key is provided. Cleaned up
all anonymous-specific code paths (isAnonymous checks, anonymous
reservation creation for unauthenticated users).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add DB fallback when fal.ai HTML pricing parsers fail

The 8+ fal.ai pricing parsers scrape HTML with regexes. If fal changes
their page structure, the parsers throw and crash the entire fal catalog
refresh.

Now each model's parse is wrapped in try/catch. On failure, fall back to
last known active DB entries for that model. If no DB fallback exists,
log an error and return empty (other models still succeed).

This prevents one broken model page from taking down pricing for all
video models.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: hoist quotedVideoCost declaration to handle catch block reference

The partial charge logic in the catch block references quotedVideoCost
which was previously declared inside the try block, causing a TypeScript
error.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: correct cache variable name in evictExpiredCacheEntries

Biome autoformatter or manual edit corrupted 'externalCatalogCache' to
'third - partyCatalogCache' in our cache eviction patch, causing build
failure on production.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: trigger fresh Vercel build (bust cache)

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(oauth): support multiple Google connections per user

Drop the single-connection-per-(user, platform) constraint so each Milady
user can link several distinct Google accounts (e.g. personal + work
gmail) simultaneously. Uniqueness is still enforced at the (organization,
platform, platform_user_id) level so the same Google account cannot be
linked twice.

- 0067 migration drops platform_credentials_user_platform_idx
- schema: remove the matching uniqueIndex
- oauth2 storeConnection: stop revoking "other" connections for the same
  user+platform on every new OAuth completion; upsert by (org, platform,
  platform_user_id) only and keep other accounts intact
- milady-google-connector: add listManagedGoogleConnectorAccounts that
  returns every connected Google account per side
- new GET /api/v1/milady/google/accounts route
- google-connection.tsx: render every active connection with per-row
  disconnect and an "Add another Google account" button
- tests: resolver multi-account listing, disconnect-by-id isolation,
  migration asserts the user_platform_idx is dropped

* fix: actually replace third-partyCatalogCache with third-partyCatalogCache

Previous commits attempted to rename but the disk file still had a
hyphen in the identifier ("third-party-catalog-cache"), which TypeScript
parses as subtraction. This sed-based fix writes the actual replacement.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: remove approve-pr.yml workflow (security risk flagged in review)

The workflow allowed anyone with workflow_dispatch access to approve any
PR number via GITHUB_TOKEN, bypassing branch protection and normal review.
Additionally had a shell injection vulnerability via unquoted
${{ inputs.pr_number }} interpolation.

This was bundled into PR 458 but unrelated to the steward auth fix.
Reviewers flagged it as a critical supply-chain risk.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat: add device-bus, twilio voice, remote sessions, billing service

Bundles substantive cloud-side work that accumulated on the
multi-google-connections branch:

- Device-bus tables, schemas, and admin/users + remote API surfaces
- Twilio voice inbound-call schemas + adapter + route
- Remote sessions schema/repo + API
- Billing service package
- Agent loader/runtime cleanups (drop legacy agent-runtime.ts)
- DB schema and migration housekeeping
- UI tweaks across character builder, chat pages, and sidebar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: address PR 458 review feedback

1. Remove PRIVY_MIGRATION_TRIAGE.md - planning docs shouldn't be in repo
2. dashboard/layout.tsx now uses useSessionAuth() - eliminates the manual
   cookie check race condition and duplicated auth logic. The steward-authed
   cookie-based check ran only once on mount; useSessionAuth subscribes to
   storage events and steward-token-sync events for reactive updates.
3. useStewardAuth() no longer violates Rules of Hooks - reads
   StewardAuthContext directly via useContext instead of try/catching
   useAuth(). Same behavior, no hook rule violation.
4. Migration 0064 now uses IF NOT EXISTS on tables and indexes per
   CLAUDE.md migration rules (prevents re-run failures in prod).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): migrate signup-prompt-banner to Next router

Replaces usePrivy().login with router.push('/login?returnTo=...'),
matching the pattern used in sidebar-bottom-panel, user-menu, and
other already-migrated auth entry points.

One of the S-rated migrations from PRIVY_MIGRATION_TRIAGE.md.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: tighten device bus google connection handling

* Clean up device bus lint issues

* fix(auth): recover from expired access token instead of logging out

The silent-logout bug: when an access token sat idle long enough to
fully expire (browsers throttle setInterval in background tabs, so a
15-min idle was enough), the auto-refresh loop had already given up
because it only ran when `secs > 0`. On top of that, syncToken would
delete the server cookie as soon as it saw an expired token, killing
any chance of server-side recovery.

This refactor:
- Drops the `secs > 0` gate in checkAndRefresh so we try to refresh
  even when the access token is fully expired (the refresh token is
  typically still valid for 30 days).
- Moves the "clear server cookie" decision to AFTER refresh fails
  instead of firing on every expired-token observation.
- Adds a visibilitychange listener so we immediately attempt a refresh
  when a tab becomes visible again (covers the 'came back from lunch'
  case where background setInterval was throttled).
- Runs an eager refresh check on mount (covers hard reload after idle).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(deps): bump @stwd/react to 0.6.6 for StewardAuthContext export

0.6.5 didn't re-export StewardAuthContext from the package index,
even though use-session-auth.ts imports it directly. Builds worked
before because of lockfile drift; Turbopack is stricter. 0.6.6 adds
the missing re-export.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): try refreshSession on login page before showing UI

Shadow reported still getting logged out of elizacloud. Traced the
bug: when the client-side refresh loop hasn't fired yet (e.g. came
back after server-rendered navigation with expired access cookie),
the middleware redirects to /login. The login page calls
`auth.getSession()` \u2014 which returns null if expired \u2014 and shows
the login UI even though a 30-day refresh token is still sitting in
localStorage.

Now the login page tries `auth.refreshSession()` before falling back
to the UI. If it succeeds, sets the cookie and redirects the user
back to where they were going. Fast-path for the "valid session"
case is preserved.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: snapshot worktree changes

* chore: snapshot cloud develop merge worktree

* chore: remove misleading deprecated labels from still-active code

auth-anonymous, Message.reasoning, isFinish fallback branches, and the
/api/mcp/stream 410 stub were all labeled "deprecated" but remain
actively in use. Reworded to describe what the code actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix cloud workflow failures

* fix steward login lint dependencies

* fix standalone biome root config

* feat: add MCP browser and search routes

* cloud: proxy lifeops schedule sync routes

* feat(auth): server-side Steward token refresh in edge proxy

Moves the refresh race out of client JS and into the edge proxy. When a
protected request arrives with an expired access token and a valid
refresh token cookie, the proxy calls Steward's /auth/refresh server-side,
rotates both cookies, and forwards the request with the new bearer so
downstream auth works on the same round trip.

- proxy.ts: refresh flow with structured [steward-auth] logging,
  transient-failure tolerance (5xx/network errors log but don't redirect),
  401 clears both cookies
- app/api/auth/steward-session/route.ts: sets both steward-token and
  steward-refresh-token httpOnly cookies, DELETE clears both
- packages/lib/providers/StewardProvider.tsx: syncs refresh token to the
  bridge alongside access token, drops visibilitychange handler (middleware
  handles return-from-idle now)
- app/login/steward-login-section.tsx: minor body-shape update for the
  new session bridge POST signature
- packages/tests/unit/steward-proxy-refresh.test.ts: coverage for
  success/401/5xx/network-error paths + same-request auth forwarding

Fixes the 15-30 min silent-logout bug.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore(fmt): biome format proxy.ts + tests

Auto-fix from biome check --write.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: bump @stwd/sdk + react + add wallet peer deps

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(login): add wallet login + GitHub OAuth to login page

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(sync): handle wallet-only Steward sessions in syncUserFromSteward

Co-authored-by: wakesync <shadow@shad0w.xyz>

* test: cover wallet-only sync path

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(login): use @stwd/react/wallet subpath import + bump to 0.7.1

The previous workaround imported WalletLogin from a deep dist path because
0.7.0 had a packaging bug (referenced BackpackWalletAdapter which is no
longer in @solana/wallet-adapter-wallets). Fixed in 0.7.1.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore(fmt): biome format steward-session route

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore(fmt): biome format login files

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(tests): cast Mock<fetch> via unknown + annotate Array callbacks

TypeScript strict mode rejected Mock<() => Promise<Response>> as typeof
fetch without an intermediate unknown cast (preconnect is missing).
Fix test file to:
- cast all globalThis.fetch mocks via 'as unknown as typeof fetch'
- type Array.some/every/find callback params as string (previously implicit any)
- cast fetchMock.mock.calls[0] as unknown[] to avoid tuple length 0 error

Unit tests continue to pass (5/5).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* ci: retrigger checks

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: checkpoint local changes

* chore(fmt): wrap long line in proxy refresh test

Co-authored-by: wakesync <shadow@shad0w.xyz>

* x updates

* cloud: execute lifeops x operations

* chore(deps): bump @stwd/react to 0.7.2 (wallet-login style fixes)

Fixes the 'Connect wallet' + 'Select Wallet' text wrap and the mismatched
EVM/Solana button sizing reported after yesterday's login UI ship.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: preserve session changes

* migrate app-auth/authorize to steward

swap privy for steward on the app-auth/authorize page and reuse
requireAuthOrApiKey on /connect and /session so both endpoints
accept privy or steward jwts through existing code paths.

- wrap authorize page in StewardAuthProvider
- replace usePrivy/useLogin with useAuth + StewardLogin
- drop unreachable "continue as" branch + unused AuthorizeFallback
- collapse connect/session routes onto requireAuthOrApiKey and
  nextJsonFromCaughtErrorWithHeaders for consistent cors errors

* fix(login): native Ethereum + Solana buttons matching Google/Discord style

Screenshot feedback: the @stwd/react WalletLogin component had a dev-tool
aesthetic (mono font, sharp corners, harsh black embedded card) clashing
with elizacloud's login card (Inter, soft corners, unified surfaces).
UX was also two-step: connect wallet, THEN click 'Sign in'.

Replaces with two native buttons (Ethereum / Solana) styled identical to
Google / Discord. One-click flow:
  1. Click button → opens RainbowKit modal (EVM) or Solana wallet modal
  2. Once wallet connects, auto-triggers SIWE / SIWS signature
  3. Success → redirect

Drops the <WalletLogin> + custom theme-var wrapper from the login page.
Keeps StewardWalletProviders for wagmi + Solana hooks context.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(db): repair _journal.json drift from migration tracking

Prod Neon DB was missing 10 tables (affiliate_codes, user_affiliates,
service_pricing, service_pricing_audit, org_rate_limit_overrides,
device_bus_devices, device_bus_intents, twilio_inbound_calls,
remote_sessions, credential_mappings in n8n_workflow schema) and 3
columns (referral_codes.parent_referral_id, referral_signups.app_owner_id,
referral_signups.creator_id), all silently broken for months.

Root cause: _journal.json was out of sync with migration files.

- 7 migration files had no journal entry (drizzle never ran them):
  0017_add_organization_encryption_keys
  0048_00..03_elite_rumiko_fujikawa_*
  0065_add_device_bus_tables
  0066_add_twilio_inbound_calls
- Duplicate idx=63 between 0067 and 0068 (data integrity bug)

Fixed journal:
- Added 7 missing entries with synthetic timestamps
- Re-sequenced idx so every entry has a unique monotonically-increasing idx
- All 72 migration files now represented in journal

Migrations were applied directly to prod via psql (all idempotent,
verified via dry-run transaction first). __drizzle_migrations table was
back-filled with 12 missing hash entries so future drizzle-kit migrate
runs see everything as applied.

Pre-existing drizzle-kit generate error about duplicate snapshot parent
(0064 referenced by both 0065 variants) is unrelated and not addressed
here; it's a generation-time issue, not a migration-time issue.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): preempt steward session expiry with eager server+client refresh

Shadow reported being signed out after ~15min, which matches the access
token TTL exactly. Two gaps in our refresh story that made this happen:

1. Server middleware only triggered /auth/refresh when the access token
   was ALREADY expired. If the user's first navigation after a long idle
   coincided with the token expiring milliseconds ago, the refresh
   window was racy. Now we preemptively refresh whenever the token has
   less than 180s remaining on any protected-path request.

2. Client-side interval timer (checkAndRefresh every 60s, triggers when
   <120s left) is unreliable in background tabs. Chrome throttles
   setInterval to roughly 1 call per minute in inactive tabs and can
   suspend them entirely when memory-pressure hits. A user returning to
   an idle-for-15-min tab would find their token gone and the interval
   hadn't fired its check. Added visibilitychange + online event
   handlers that force an immediate check-and-refresh when the tab
   becomes visible or the network reconnects.

Together these close the window in which an expired token reaches any
server request handler. The server-side middleware is the real backstop
since it runs for every protected request regardless of tab state.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(ai-pricing): store gateway token rows under language/embedding

Image-generation models (e.g. gemini-*-image) were classified as product
family image for all pricing rows, including per-token input/output.
Chat billing resolves language:input, so lookups failed after catalog
refresh. Token and web_search entries now use embedding vs language
only; image-specific rows remain under image.

Made-with: Cursor

* fix(ai-pricing): resolve Anthropic snapshot ids to gateway catalog ids

Requests use dated API ids (e.g. anthropic/claude-sonnet-4-5-20250929) while
the gateway and OpenRouter catalogs list stable ids (anthropic/claude-sonnet-4.5).
Expand catalog lookup candidates by stripping snapshot dates and normalizing
version hyphens (4-5 -> 4.5).

Made-with: Cursor

* feat(ai-pricing): gateway model alias table for renamed ids

Adds GATEWAY_PRICING_MODEL_ALIASES (legacy id -> current catalog ids) with
automatic reverse lookup so new ids still match older persisted pricing rows.
Anthropic snapshot normalization remains; teams can append explicit Vercel
rename mappings as the gateway catalog changes.

Made-with: Cursor

* chore(ai-pricing): populate gateway model rename aliases from AI SDK history

Derives legacy -> current ids from vercel/ai gateway-language-model-settings
changes (#6828, #7249, #6544) and validates targets against the live
ai-gateway catalog. Notes approximate xAI grok-2* -> grok-3* successors.

Made-with: Cursor

* test(ai-pricing): gateway product family regression + refresh docs

- Document that catalog refresh deactivates all active rows for the
  snapshot source_kind before insert (no orphan active token rows with
  wrong product_family).
- Inline token product family logic; export buildGatewayPreparedEntries
  and GatewayCatalogModel for tests.
- Add unit tests: image-generation models keep language token rows and
  image generation rows; embedding models use embedding family.

Made-with: Cursor

* fix(ai-pricing): widen GATEWAY_PRICING_MODEL_ALIASES type for string index

as const + satisfies produced a closed key set so GATEWAY_PRICING_MODEL_ALIASES[canonicalModel]
failed typecheck; use a single Record assertion instead.

Made-with: Cursor

* fix: ai-pricing aliases, lint/types, and ambient stubs

AI pricing: resolve DB/live rows using the provider inferred from each catalog candidate (cross-provider gateway aliases). Batch active pricing lookups via listActiveEntriesForProviderModelPairs. Precompute GATEWAY_PRICING_LEGACY_IDS_BY_TARGET for reverse alias lookup, cap Anthropic suffix normalization iterations, and warn when pricing is matched via a non-canonical model id.

Tooling: exclude packages/types ambient .d.ts from Biome to avoid internal parser panics; repair provisioning-worker shebang for Biome parsing.

Types: add ambient declarations for @elizaos/billing, music-metadata, Steward, and optional wallet deps; align Eliza runtime/MCP, API routes, webhooks, and tests with current typings and Biome formatting.
Made-with: Cursor

* fix(auth): type steward login component

* feat(x): charge credits for cloud operations

* fix(types): cover steward and wallet auth helpers

* fix(db): allow historical duplicate migration prefixes

* fix(types): retry silent split typecheck kills

* fix(x): return json errors from cloud routes

* fix(types): bound split typecheck concurrency

* fix(x): preserve upstream error statuses

* fix(tests): align mcp registry status expectations

* fix(auth): send tenant_id (snake_case) to steward /auth/oauth authorize

The Steward authorize route reads the param as 'tenant_id' via
c.req.query('tenant_id'). We were sending camelCase 'tenantId', so the
server silently fell back to the user's personal tenant
('personal-<userId>') and issued refresh tokens scoped to that tenant.
Those tokens work for personal-tenant calls but not for elizacloud
endpoints, so any session that went through GitHub/Google/Discord OAuth
got knocked off elizacloud once the Privy path fell away.

DB evidence: Shadow's user has 4 personal-tenant refresh tokens
interleaved with elizacloud tokens, exactly at timestamps matching
OAuth login attempts.

Fix is one param rename. Server side will be patched to accept both
variants in a follow-up for robustness.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(x): complete managed x endpoints and tests

* fix(api): harden openapi server metadata

* test(oauth): align twitter connection ids

* fix(tests): stabilize integration server reuse

* docs: refresh llms model ids

* fix(db): make legacy cleanup migration idempotent

* fix(x): support oauth2 lifeops connections

* Fix Twitter OAuth role status

* Separate OAuth secrets by connection role

* fix(db): make legacy cleanup migration idempotent

Co-authored-by: wakesync <shadow@shad0w.xyz>

* test(auth): cover steward tenant_id authorize param

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: bump typescript to ^6.0.0 across workspace

Made-with: Cursor

* refactor: rename OAuth API coverage and refresh UI copy

* fix(login): preserve query string on OAuth redirect_uri

returnTo (used by /auth/cli-login, app-authorize, etc.) must survive
the OAuth round-trip; otherwise users signing in from a deep-linked
page land on /dashboard instead of the page that redirected them to
/login.

Made-with: Cursor

* add market data

* fix: use live polymarket market ordering

* fix: sync install metadata for CI

* fix: unblock cloud CI workflows

* feat(cloud): Twitter OAuth2 client, Gmail subscription headers, Google connector

Add twitter-automation oauth2-client with route and unit tests; extend Milady
Google connector and Gmail subscription-headers API; refresh token handling
and Twitter connection UI.

Made-with: Cursor

* fix(security): set COOP same-origin-allow-popups on /app-auth/* routes

The OAuth popup-callback flow opens a provider popup, polls
window.closed to detect completion or cancellation, and then exchanges
the returned code/token. Default Cross-Origin-Opener-Policy of
'same-origin' (or any CORP-isolated default) makes that closed-check
throw, so the parent window never notices the popup finishing and the
user is stuck in a re-prompt loop.

'same-origin-allow-popups' keeps the page cross-origin-isolated for
its own resources but exempts popups it opened, which is the contract
the Steward + Privy SDKs are coded against.

Scoped to /app-auth/* so dashboard / app pages keep the stricter
default. No other change to the existing CSP/security header set.

* feat(app-credits): debit org balance instead of per-app pool

reworks AppCreditsService.checkBalance / deductCredits / reconcileCredits
to read and write organizations.credit_balance via creditsService instead
of the per-app app_credit_balances table. signed-in cloud users can now
spend their org balance on any monetized app without pre-purchasing a
per-app pool. app developers still earn the markup % via the existing
recordCreatorEarnings -> redeemableEarnings path.

messages route now passes estimatedBaseCost=0 so reconcile charges the
full actual cost as the diff (the anonymous reservation never debited
upfront, so there is nothing to refund).

verified against local cloud: chat call dropped org balance from
1000.000000 to 999.999744 (actual 0.000256), per-app pool unchanged,
and a "App reconciliation charge (eDad)" debit row landed in
credit_transactions.

* fix(app-auth): rebuild authorize page with explicit consent + truncate user menu

prior auth flow auto-redirected on isAuthenticated and bailed silently
when useAuth().user was null (session loaded but user record not yet
hydrated). result: signed-in users saw only a Cancel button with no way
to continue. fixed by switching to standard oauth consent ux:
- signed out: <StewardLogin> form + Cancel
- signed in:  "Signed in as <email>" + "Authorize <app>" + Cancel

also fetches the email from /api/v1/user when the steward jwt omits it,
so the consent label shows the real address instead of "your account".

user dropdown in the top-right was overflowing on long emails; added
truncate + min-w-0 + title for hover-to-see-full so it stays inside the
w-56 menu.

minor: dropped a redundant intermediate `result` var in
deductCredits — references orgDeduct.newBalance directly.

* refactor(authorize-content): tighten per 5-rule audit

- inline single-call-site redirectWithError helper into handleCancel
- drop "wide" prop from Frame (only ever passed one value)
- narrow AppHeader's appInfo to non-null via early-return guard;
  remove the impossible "?? "A"" / "?? "Application"" / "App logo"
  defensive fallbacks (AppInfo.name is required by the cloud schema)

no behaviour change. types tightened, dead defensive code removed.

* feat(cloud): image route hardening + GPT Image 2 + tighten role error

* wip: milady-plaid-connector skeleton

* wip: paypal + plaid milady route stubs and deps

* fix(app-auth): unblock local Steward in CSP + drop hydration-prone header

CSP previously only included loopback origins (localhost:3000/3200) when
NODE_ENV=development. Running `next start` against a local Steward
instance with NEXT_PUBLIC_STEWARD_AUTH_ENABLED=true sets NODE_ENV to
production, so the CSP stripped the Steward origin and the browser
refused @stwd/react's fetch to localhost:3200/auth/providers — the
StewardLogin form rendered blank. Widen the gate to also include local
loopback when STEWARD_AUTH_ENABLED is true.

Authorize page: dropped LandingHeader. The header renders different
markup on server vs client based on auth state ("Log in" vs "Dashboard"),
which threw a React hydration error and remounted the whole tree —
that prevented validateApp's effect from completing, leaving the page
stuck on "Verifying application...". Standard OAuth consent UIs
(Google, GitHub) keep this screen header-less for the same reason.

Also dropped a dead <video> tag pointing at an asset not in the repo
(was 404'ing in 60s+ on every page load) and inlined its 1-line
wrapper, replaced with a static gradient.

* feat(app-auth): conditional hero video, gradient fallback

Re-add the /videos/Hero* asset on the OAuth consent screen layered over
the existing gradient. Browsers fire onError on 404, which we use to
display:none the <video> element so the gradient underneath stays
visible. Net effect:

- prod (asset present): plays the hero loop, same as before
- local / self-hosted (asset missing): silently falls back to gradient

No conditional fetch or env check — the browser's own load behaviour
decides at runtime, so the same component works for both deploys.

* fix(app-auth): drop email lookup, drop dead video tag

Removed two non-essential pieces from the consent screen:

1. The email-fetch effect + accountEmail state. We were chasing
   `user.email` from useAuth, then falling back to a /api/v1/user
   round-trip when the Steward session didn't surface it. The label
   now just reads "Signed in" with the green check — same intent,
   no network dependency, no slow path on cloud cold-start.

2. The <video> hero element. Local dev doesn't ship the asset and
   the resulting 404 was adding noticeable load time. Solid gradient
   stays.

Net: -55 lines, simpler component, faster page.

* chore: checkpoint local workspace changes

* test: update cloud sdk client coverage

* build: add cloud sdk dist output

* fix: checkpoint cloud sdk http update

* build: update cloud sdk http sourcemap

* test: checkpoint cloud sdk live e2e update

* fix: checkpoint cloud sdk openapi auth updates

* chore: checkpoint cloud sdk source updates

* build: checkpoint cloud sdk dist updates

* docs: checkpoint cloud sdk readme update

* chore: checkpoint cloud connector route updates

* fix(app-auth): return to authorize page after login

* chore: refresh cloud lockfile

* chore(deps): bump uuid to ^14.0.0 (GHSA-w5hq-g745-h8pq)

uuid v3/v5/v6 missing buffer bounds check when external buf is provided.
v14 adds the same RangeError guard as v1/v4/v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: isolate twitter oauth route test

* chore: refresh lockfile after uuid bump

* chore: add cloud sdk route coverage audit

* feat: expose generated cloud public routes in sdk

* Use direct eliza package imports

* fix: preserve app auth return after login

* fix: stabilize cloud lint and plugin sql types

* feat(milady-google): list managed calendars (#472)

* feat(cloud): image route hardening + GPT Image 2 + tighten role error

* wip: milady-plaid-connector skeleton

* wip: paypal + plaid milady route stubs and deps

* chore: checkpoint cloud connector route updates

* chore: checkpoint local workspace changes

* test: update cloud sdk client coverage

* build: add cloud sdk dist output

* fix: checkpoint cloud sdk http update

* build: update cloud sdk http sourcemap

* test: checkpoint cloud sdk live e2e update

* fix: checkpoint cloud sdk openapi auth updates

* chore: checkpoint cloud sdk source updates

* build: checkpoint cloud sdk dist updates

* docs: checkpoint cloud sdk readme update

* fix(app-auth): return to authorize page after login

* chore: refresh cloud lockfile

* chore(deps): bump uuid to ^14.0.0 (GHSA-w5hq-g745-h8pq)

uuid v3/v5/v6 missing buffer bounds check when external buf is provided.
v14 adds the same RangeError guard as v1/v4/v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add cloud sdk route coverage audit

* feat: expose generated cloud public routes in sdk

* Use direct eliza package imports

* test: isolate twitter oauth route test

* chore: refresh lockfile after uuid bump

* fix: preserve app auth return after login

* fix: stabilize cloud lint and plugin sql types

* feat(milady-google): list managed calendars (#472)

* chore: biome formatter auto-fixes after develop rebase

* Format generated SDK dist

Made-with: Cursor

* fix(app-credits): also pass estimatedBaseCost=0 in chat/completions

The reconcile path expects estimatedBaseCost to reflect what was already
debited upfront. Both messages/route.ts and chat/completions/route.ts use
createAnonymousReservation() (no real upfront debit) for the app-credits
path, so both must report 0 to make reconcile charge the full actual cost.
messages/route.ts was fixed earlier in this PR; this brings chat/completions
in line so monetized apps don't under-charge users by the estimate amount.

* Use direct shared contract import

---------

Co-authored-by: Sol <sol@shad0w.xyz>
Co-authored-by: wakesync <shadow@shad0w.xyz>
Co-authored-by: hanzlamateen <hanzlamateen@live.com>
Co-authored-by: dexploarer <dexploarer@gmail.com>
Co-authored-by: Odilitime <janesmith@airmail.cc>
Co-authored-by: standujar <s.andujar@proton.me>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: RemilioNubilio <275382225+RemilioNubilio@users.noreply.github.com>
Co-authored-by: nubs <nubs@iqlabs.dev>
Co-authored-by: dutchiono <86275975+dutchiono@users.noreply.github.com>
lalalune added a commit that referenced this pull request Apr 27, 2026
* fix: steward URL + CSP for eliza cloud login

- Use NEXT_PUBLIC_STEWARD_API_URL (eliza.steward.fi) not api.steward.fi
- Add steward domains to Content-Security-Policy connect-src

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove duplicate StewardProvider from login section

The root layout already wraps everything in StewardAuthProvider.
Having a second StewardProvider in the login section caused context
conflicts and prevented provider discovery (no Google/Discord buttons).

Now uses the root provider's context directly.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: auth redirect in useEffect + loading state when authenticated

- router.replace must be in useEffect, not render phase
- Show spinner + 'Redirecting...' instead of empty card when already auth'd
- StewardLogin still shows for unauthenticated users

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat: steward session cookie bridge for server-side auth

- getCurrentUser() now checks steward-token cookie alongside privy-token
- POST /api/auth/steward-session: verifies steward JWT, sets httpOnly cookie
- DELETE /api/auth/steward-session: clears cookie on logout
- AuthTokenSync calls the session API when steward auth state changes
- JIT user sync from steward (mirrors Privy JIT sync pattern)

This bridges localStorage (client) → cookie (server) so dashboard
pages can authenticate steward users via requireAuth().

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: use local StewardProvider for login (fixes OAuth button visibility)

Root layout and login page may bundle @stwd/react separately in
Turbopack, creating different React contexts. This means the login
page's StewardLogin can't see the root provider's discovered providers.

Fix: use a local StewardProvider in the login section so provider
discovery happens in the same context as StewardLogin. Also wire
the session cookie bridge directly in onSuccess.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove require() hack that broke Turbopack build

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: custom login UI using @stwd/sdk directly (bypasses React context)

Turbopack bundles @stwd/react into separate chunks for the root layout
and login page, creating different React.createContext instances.
StewardLogin can never see providers from its wrapping StewardProvider.

Solution: skip @stwd/react entirely for the login form. Use @stwd/sdk's
StewardAuth class directly to:
- Fetch providers (google/discord/passkey/email)
- Handle passkey, email magic link, and OAuth sign-in
- Set session cookie via /api/auth/steward-session
- Redirect to dashboard

Custom UI matches Eliza Cloud's dark/orange design system.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: bypass Privy middleware for steward-session route

proxy.ts middleware catches ALL /api/* routes and requires a valid
Privy token. Our /api/auth/steward-session route validates its own
steward JWT, so it needs to be in the public bypass list.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: redirectUrl → redirectUri (StewardOAuthConfig type)

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: middleware reads steward-token cookie + replace emojis with icons

- proxy.ts middleware now checks steward-token cookie alongside privy-token
  for protected paths like /dashboard (was redirecting back to /login)
- Replaced emoji passkey (🔑) and email (✉️) with proper SVG icons

Co-authored-by: wakesync <shadow@shad0w.xyz>

* debug: add steward-debug endpoint to trace auth failure

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: pass elizacloud tenantId to StewardAuth + debug sync

Without tenantId, steward issues tokens with personal-{uuid} tenant
instead of elizacloud. Also enhanced debug endpoint to test JIT sync.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: use window.location.href instead of router.replace for redirect

Next.js router.replace may be blocked by layout-level auth guards.
Hard navigation ensures the cookie is sent with the new request.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: skip StewardProvider on /docs pages (nextra CSS conflict)

The @stwd/react StewardProvider wraps in a stwd-root div with CSS
custom properties that override nextra's dark theme, causing a black
screen on docs pages. Skip it on /docs routes.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: skip Privy verification for steward tokens + neutralize stwd-root CSS

1. proxy.ts: steward-token cookies are HS256 (not RS256 like Privy).
   When only steward-token is present, pass through middleware without
   Privy verification. getCurrentUser() handles HS256 verification.

2. globals.css: neutralize .stwd-root background/color so the wrapper
   div from @stwd/react doesn't override page themes (fixes docs blackout,
   works for all pages without pathname checks).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove stray brace from StewardProvider

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: remove stray brace in StewardProvider (rebase artifact)

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: skip StewardProvider entirely on docs/blog pages

CSS neutralization alone may not be sufficient. The stwd-root div's
JS initialization can still crash nextra hydration. Skip the entire
provider on /docs and /blog routes.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: upgrade @stwd/react to 0.6.5 + remove slop patches

@stwd/react@0.6.5 no longer wraps children in a stwd-root div.
Removed all workarounds:
- pathname check for /docs in StewardAuthProvider
- CSS !important override for .stwd-root in globals.css
- usePathname import

Co-authored-by: wakesync <shadow@shad0w.xyz>

* cloud: handle missing request context apis in tests

* feat(#55): Gateway — enrich POST body with platform metadata

* refactor(#55): align tests with live-test pattern, extract pure helpers

Replace heavy-mock test suites (mock.module, fake Redis, spyOn fetch)
with pure-function tests following the post-scrub testing convention.

Extract testable helpers: buildForwardBody(), resolveSource(),
resolveUserName(), buildConnectionMetadata() — tested without mocks.

Made-with: Cursor

* fix(#55): address PR review — PII, validation, metadata guard

- Remove chatId from all debug log payloads (PII for Twilio/WhatsApp)
- Add KNOWN_PLATFORMS allowlist guard on resolveSource() and
  buildConnectionMetadata() to reject unrecognized platformName values
- Only construct metadata object in routes.ts when at least one field
  is present (pass undefined for backward-compatible path)
- Add 4 new tests for allowlist validation edge cases

Made-with: Cursor

* fix(#55): address follow-up review — orphaned chatId, length guard, clean metadata

- chatId now requires a valid platformName to be stored in connection
  metadata (orphaned chatId without platform is useless for routing)
- senderName capped at 255 chars in resolveUserName() to prevent
  oversized values from reaching the database
- routes.ts builds metadata via conditional spread (no undefined values)
- Debug log gated behind metadata presence (no misleading log when
  all fields are absent)
- PII comments added to all log sites explaining senderName/chatId
  omission
- Documented the unofficial metadata extension on ensureConnection cast

Made-with: Cursor

* cloud: stabilize auth and browser test coverage

* fix(#55): runtime type guards, validated log payloads, chatId discard warning

- Add `typeof === "string"` guards on optional body fields in routes.ts
  to reject non-string values at runtime, not just compile time
- Move debug log from routes.ts into handleMessage and log the validated
  `source` (post-resolveSource) instead of raw `platformName` from the
  request body — prevents arbitrary strings from entering structured logs
- Gate the debug log behind `if (metadata)` so it only fires when
  platform context is actually present
- Add `logger.warn` in buildConnectionMetadata when chatId is discarded
  due to an unrecognized platformName — makes silent data loss visible
- Annotate KNOWN_PLATFORMS with a sync comment pointing to Platform type
  in gateway-webhook and SUPPORTED_PLATFORMS in the webhook config route
- Change KNOWN_PLATFORMS to ReadonlySet<string> for immutability

Made-with: Cursor

* fix(#55): chatId length cap, narrowed type cast, edge-case test coverage

- Add MAX_CHAT_ID_LENGTH (128) and truncate chatId in
  buildConnectionMetadata to match the senderName length-cap pattern
- Narrow the ensureConnection type cast to an intersection
  (Parameters<…>[0] & { metadata?: … }) so the compiler still validates
  the standard fields while allowing the unofficial metadata extension
- Add two new tests: valid platform with empty chatId (omitted from
  result), and chatId truncation at 128 chars
- Add guard comment on the metadata ternary in routes.ts explaining why
  an empty {} must not reach handleMessage

Made-with: Cursor

* fix(#55): warn on unrecognized platformName, add TODO for upstream cast

- resolveSource now logs logger.warn when a non-empty platformName is
  not in KNOWN_PLATFORMS, making misconfigured adapters observable
  without waiting for a chatId to be attached
- Replace descriptive comment on ensureConnection cast with an
  actionable TODO so the cleanup is discoverable when upstream adds
  the metadata field
- Remove unnecessary optional chain on metadata.chatId after
  validPlatform is confirmed (metadata cannot be nullish at that point)

Made-with: Cursor

* fix(#55): deduplicate warnings, single body cast, spy assertions in tests

- Remove duplicate warn from buildConnectionMetadata — resolveSource
  already warns on unrecognized platformName. buildConnectionMetadata
  now emits logger.debug when chatId is discarded (covers both
  no-platform and unrecognized-platform paths)
- Consolidate routes.ts to a single `body as Record<string, unknown>`
  cast with typeof guards on all five fields (userId, text, platformName,
  senderName, chatId)
- Add spyOn(logger) assertions in metadata-helpers.test.ts to verify
  the warn and debug log paths fire correctly
- Tag the ensureConnection TODO with (#55) for discoverability

Made-with: Cursor

* fix(#55): restore mocks after tests, deduplicate logs, tighten return type

- Add afterEach(() => mock.restore()) so logger spies don't leak across
  tests when Bun shares a worker process
- Suppress buildConnectionMetadata debug log when platformName is
  present-but-unrecognized (resolveSource already warns for that case);
  debug now only fires for chatId-without-any-platformName
- Tighten buildForwardBody return type from Record<string, string> to
  { userId: string; text: string } & Partial<ForwardMessageOptions>

Made-with: Cursor

* feat: add OpenRouter as fallback AI provider alongside Vercel Gateway

When Vercel AI Gateway is unavailable or out of credits, inference
requests automatically fall through to OpenRouter. Supports both
configuration-level fallback (OPENROUTER_API_KEY without gateway key)
and per-request failover on 402/429 errors for raw HTTP proxy routes.

- Add OpenRouterProvider raw HTTP class (chat/completions, embeddings, models)
- Add withProviderFallback() utility for automatic 402/429 retry
- Wire OpenRouter into getLanguageModel/getProviderForModel resolution chains
- Add free model mapping for fallback (gateway models → OpenRouter :free tier)
- Integrate OpenRouter model catalog with SWR caching
- Fix models/status and models/[...model] routes to work in OpenRouter-only mode
- Add OPENROUTER_API_KEY to env-validator AI feature check

* feat: dynamic per-org rate limits based on cumulative spend

Replace hardcoded rate limit presets with automatic tier-based limits
per organization. Tiers are computed from paid credit purchases (excluding
free/bonus credits) and cached in Redis (1h TTL).

Tiers: free (60rpm), paid >= $5 (120rpm), growth >= $100 (300rpm).
Enterprise overrides via org_rate_limit_overrides table + admin API.

* fix: address code review feedback on org rate limits

- Add tier cache invalidation on app purchases (not just regular purchases)
- Parallelize DB queries in recalculateOrgTier (credit sum + override lookup)
- Sort tier thresholds defensively before matching
- Add max(10000) cap on admin RPM override values
- Fix PATCH null handling: null clears a field, omitted = no change
- Remove unused notInArray import

* fix: address second review — UUID validation, static imports, shared counter comment

- Validate orgId as UUID in admin endpoint (400 instead of 500 on invalid)
- Replace dynamic import() with static import for EndpointType and getOrgRpmForEndpoint
- Add comment clarifying /responses shares "completions" counter intentionally

* test: add unit tests for org rate limit tier system

25 tests covering:
- Tier threshold matching (free/paid/growth boundaries)
- Override merging (partial, all-null, no override)
- Endpoint RPM mapping (completions/embeddings/standard/strict)
- Route wiring verification (source code parity checks)
- Config parity (test values match source constants)

* fix: address third review — Redis gate, upsert safety, index, sort

- Skip enforceOrgRateLimit when REDIS_RATE_LIMITING !== "true" (dev/staging)
- Upsert SET only includes explicitly provided fields (preserves existing)
- Add composite index (organization_id, type) on credit_transactions
- Sort tier thresholds once at module load instead of per call

* fix: address fourth review — docs, export, tests

- Document superadmin-only auth on admin endpoint
- Add comment on PATCH null/omit semantics
- Export OrgTierData type
- Add test: free credits excluded from tier computation
- Add test: null clears override field back to tier default

* fix: reject empty PATCH, add CHECK constraint on rpm columns

- Reject PATCH with no override fields (prevents misleading "custom" tier)
- Add DB CHECK constraint: rpm columns must be NULL or > 0

* fix: remove non-null assertions, handle cache write failure

- Replace organization_id! with defensive if-guard in completions and embeddings
- Wrap cache.set in try/catch so Redis failure doesn't 500 the request
- Add CHECK constraint documentation comment in schema file

* fix: all-null override keeps base tier name, validate org exists on PATCH

- tierName only set to "custom" when at least one RPM field is non-null
- Admin PATCH/DELETE returns 404 if org doesn't exist (instead of FK 500)
- Update tests to match new all-null override behavior

* cloud: fix lint formatting on model catalog routes

* cloud: fix post-merge lint cleanup

* cloud: fix gateway webhook test paths

* cloud: align gateway webhook bun version

* cloud: prefix gateway webhook test filters

* cloud: run current gateway webhook test files

* fix: register org_rate_limit_overrides migration in Drizzle journal

The migration was written manually in PR #452 but never registered via
`db:generate`. This replaces it with a Drizzle-generated migration
(0060_zippy_joshua_kane.sql) that includes the table, FK, CHECK
constraint, and composite index.

(cherry picked from commit 7436ffdc1e34b04fffaa145be4bfa9eab75ce718)

* cloud: renumber org rate limit journal migration

* cloud: add a canonical ai pricing catalog

* cloud: bill inference endpoints from live catalog

* cloud: expose live pricing refresh and summaries

* fix(typecheck): tighten gateway metadata helpers

* cloud: patch postinstall and api route discovery

Normalize published @elizaos/core bundles away from zod loose() at install time and teach public route discovery to detect re-exported HTTP handlers.

* fix: move steward passthrough after bearerToken declaration

bearerToken was referenced before its const declaration (temporal dead
zone), causing a ReferenceError that crashed the middleware and redirected
to /auth/error on every steward-authenticated request.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: patch pricing, privacy, and security issues in live-ai-pricing (#456)

* fix: implement Seedance 2.0 pricing parser and add models to catalog

Implement the seedance pricing parser that was throwing an unimplemented
error, crashing the entire pricing refresh cron when Seedance models
appeared in the catalog.

Parser extracts per-second pricing from fal.ai model pages:
- Seedance 2.0: $0.3034/second (720p)
- Seedance 2.0 Fast: $0.2419/second (720p)

Audio is included in the base price (no audio dimension needed).

Also adds both models to SUPPORTED_VIDEO_MODELS in the definitions file.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: document public-only blob access limitation

Vercel Blob only supports access: 'public' as of 2026-04. Added TODO
comment explaining the limitation and noting that a proper fix requires
an auth-gated proxy route to serve blob content with session validation.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: charge ~10% on failed video generation instead of full refund

Shaw changed failed video reconciliation from partial charge to full
refund (reconcile(0)), creating an abuse vector where users could
trigger failures intentionally at zero cost while fal.ai still charges
for the compute attempt.

Restore partial charge at ~10% of quoted cost for all failure paths:
- No video URL in response
- Blob upload failure
- General generation error

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add error handling to /api/v1/pricing/summary endpoint

Wrap each model cost lookup in try/catch so one failing fal endpoint
(or any third-party catalog) won't 500 the entire public unauthenticated
route.

Failed lookups are filtered out and partial results returned with a
warnings array. Categories with zero successful lookups are omitted
from the response entirely.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add is_public column to generations, filter explore gallery

listRandomPublicImages() was returning ALL completed images across all
users, leaking private generations in the explore/discover section.

Added is_public boolean column (default false) via migration 0065.
Updated the query to filter by is_public = true so only explicitly
opted-in content appears in the explore gallery. Includes a partial
index on is_public WHERE true for query performance.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: evict expired entries from third-partyCatalogCache to prevent unbounded growth

The module-level third-partyCatalogCache Map was never pruned. While in
practice only ~4 keys are used (gateway, openrouter, fal, elevenlabs),
expired entries were never removed. Add eviction of expired entries
before inserting new ones in getCachedExternalEntries.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: require authentication for image generation, remove anonymous fallback

Anonymous users could generate images for free by hitting the endpoint
without auth. The authenticateUser() function silently fell back to
creating anonymous users who bypassed all credit checks.

Now returns 401 if no valid session or API key is provided. Cleaned up
all anonymous-specific code paths (isAnonymous checks, anonymous
reservation creation for unauthenticated users).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add DB fallback when fal.ai HTML pricing parsers fail

The 8+ fal.ai pricing parsers scrape HTML with regexes. If fal changes
their page structure, the parsers throw and crash the entire fal catalog
refresh.

Now each model's parse is wrapped in try/catch. On failure, fall back to
last known active DB entries for that model. If no DB fallback exists,
log an error and return empty (other models still succeed).

This prevents one broken model page from taking down pricing for all
video models.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: move steward passthrough after bearerToken declaration

bearerToken was referenced before its const declaration (temporal dead
zone), causing a ReferenceError that crashed the middleware and redirected
to /auth/error on every steward-authenticated request.

Co-authored-by: wakesync <shadow@shad0w.xyz>

---------

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: dashboard layout accepts steward auth (cookie check)

The dashboard layout's client-side auth check only looked at Privy's
usePrivy().authenticated. Steward users have a steward-token cookie
but no Privy session, causing a redirect loop.

Now checks for steward-token cookie alongside Privy auth.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* test: seed affiliate billing pricing data

* fix(auth): recognize steward sessions in dashboard chrome

- treat steward auth as signed-in for user menu, sidebar CTA, and locked nav items
- fetch server profile/org balance for steward-backed sessions
- clear steward session on sign out

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): sweep generic ui flows for steward sessions

- add shared hybrid session auth hook for privy + steward
- update landing, invite, payment success, admin, chat, and settings surfaces
- keep generic session-aware UI from rendering logged-out states for steward users
- preserve privy-specific flows where bearer token login is still intentional

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(admin): remove stale wallets reference after useSessionAuth migration

Co-authored-by: wakesync <shadow@shad0w.xyz>

* ci: add workflow dispatch for PR approval

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): make steward auth hook safe outside StewardProvider

useStewardAuth() now returns fallback defaults when called outside
<StewardProvider>, preventing crash when STEWARD_AUTH_ENABLED is false
or the provider is conditionally unmounted. All direct @stwd/react
imports replaced with the safe wrapper from use-session-auth.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): stop AuthTokenSync from deleting cookies on initial mount

AuthTokenSync was calling DELETE /api/auth/steward-session on first
render because isAuthenticated starts as false before the provider's
useEffect reads localStorage. This wiped the steward-authed cookie
before the dashboard could see it.

Now only clears cookies on actual sign-out (wasAuthenticated ref
tracks whether we were ever authed in this session).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): stabilize auth prop to prevent StewardAuth recreation on every render

The `auth={{ baseUrl: apiUrl }}` inline object literal caused a new
reference on every render, which made @stwd/react's internal useMemo
re-create the StewardAuth instance every render. That thrashed the
authSession state and prevented isAuthenticated from settling to true
even when a valid JWT existed in localStorage.

Now memoizing the auth config object so the internal StewardAuth is
created once per apiUrl change.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): read steward session directly from localStorage

Bypass @stwd/react's internal auth state (which was not reliably
initializing from localStorage during hydration) by:

1. useSessionAuth now reads the steward JWT directly from localStorage
   in addition to the provider's isAuthenticated. If either says we're
   logged in, the UI treats the user as authenticated.

2. AuthTokenSync now syncs the localStorage token to the server cookie
   on mount regardless of what the provider reports, and only DELETEs
   the cookie on actual token removal (not on initial false state).

This makes the dashboard chrome and auth-gated UI robust to the
provider's hydration timing and any state-thrashing issues.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): migrate chrome components to useSessionAuth

sidebar-item, sidebar-bottom-panel, and user-menu were still using the
thin useStewardAuth wrapper which only checks the provider's internal
auth state. Switching them to useSessionAuth which reads localStorage
directly, so steward auth is detected even when @stwd/react's provider
is slow/failing to initialize.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): use server-side redirect flow for OAuth

The SDK's popup-based signInWithOAuth expects the server to redirect
back with code+state so the client can do its own PKCE exchange, but
steward does the full exchange server-side and redirects back with
token+refreshToken directly. That mismatch caused "OAuth state
mismatch, possible CSRF attack" errors.

Now: OAuth button triggers a full-page redirect to
${STEWARD_API_URL}/auth/oauth/:provider/authorize?redirect_uri=...
Steward handles everything and redirects back to /login?token=...
where we store tokens in localStorage and continue the normal flow.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): switch CLI login page to steward session

CLI login page was using usePrivy which doesn't see steward sessions.
Now uses useSessionAuth so both Privy and Steward users can authenticate
to generate CLI API keys. Sign In button routes through the normal
/login?returnTo=... flow which already supports steward passkey, email,
and OAuth.

The server-side /api/auth/cli-session/[sessionId]/complete endpoint
already uses requireAuthWithOrg -> getCurrentUser which reads both
steward-token and privy cookies, so no backend changes needed.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(auth): auto-refresh steward JWT before expiry

Adds a periodic check (every 60s) that refreshes the steward access
token when it has fewer than 120 seconds remaining. Uses a standalone
StewardAuth instance that calls refreshSession() (exchange refresh
token for new access token) and re-syncs the updated JWT to the
server cookie.

This prevents the 15-min JWT expiry from silently logging users out
and breaking server-side auth (requireAuthWithOrg).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(auth): display error states on login page for failed callbacks

When the steward email or oauth callback fails, the backend now
redirects to /login?error=...&reason=.... Surface those errors
as a friendly inline message on the login page, map reason codes
to human text, and clean the URL after display.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* docs(auth): triage remaining Privy callsites for migration

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(oauth): allow Milady-origin redirect URLs in generic callback

The generic OAuth callback's local isValidRedirectUrl was same-origin-only,
so Google OAuth flows initiated from Milady (desktop/web) landed on the
cloud dashboard instead of bouncing back to the originating app. Replace
the bespoke validator with the shared redirect-validation helpers plus a
new resolveOAuthSuccessRedirectUrl that accepts:

  - relative paths on the cloud base URL
  - absolute URLs on the cloud base URL
  - absolute URLs on allowlisted Milady origins (milady.ai, app.milady.ai,
    extras, plus wildcard loopback for desktop dev)

Also validate redirectUrl up front in the initiate route so bad inputs
fail with 400 instead of silently falling back on callback.

* fix: implement Seedance 2.0 pricing parser and add models to catalog

Implement the seedance pricing parser that was throwing an unimplemented
error, crashing the entire pricing refresh cron when Seedance models
appeared in the catalog.

Parser extracts per-second pricing from fal.ai model pages:
- Seedance 2.0: $0.3034/second (720p)
- Seedance 2.0 Fast: $0.2419/second (720p)

Audio is included in the base price (no audio dimension needed).

Also adds both models to SUPPORTED_VIDEO_MODELS in the definitions file.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: document public-only blob access limitation

Vercel Blob only supports access: 'public' as of 2026-04. Added TODO
comment explaining the limitation and noting that a proper fix requires
an auth-gated proxy route to serve blob content with session validation.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: charge ~10% on failed video generation instead of full refund

Shaw changed failed video reconciliation from partial charge to full
refund (reconcile(0)), creating an abuse vector where users could
trigger failures intentionally at zero cost while fal.ai still charges
for the compute attempt.

Restore partial charge at ~10% of quoted cost for all failure paths:
- No video URL in response
- Blob upload failure
- General generation error

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add error handling to /api/v1/pricing/summary endpoint

Wrap each model cost lookup in try/catch so one failing fal endpoint
(or any third-party catalog) won't 500 the entire public unauthenticated
route.

Failed lookups are filtered out and partial results returned with a
warnings array. Categories with zero successful lookups are omitted
from the response entirely.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add is_public column to generations, filter explore gallery

listRandomPublicImages() was returning ALL completed images across all
users, leaking private generations in the explore/discover section.

Added is_public boolean column (default false) via migration 0065.
Updated the query to filter by is_public = true so only explicitly
opted-in content appears in the explore gallery. Includes a partial
index on is_public WHERE true for query performance.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: evict expired entries from third-partyCatalogCache to prevent unbounded growth

The module-level third-partyCatalogCache Map was never pruned. While in
practice only ~4 keys are used (gateway, openrouter, fal, elevenlabs),
expired entries were never removed. Add eviction of expired entries
before inserting new ones in getCachedExternalEntries.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: require authentication for image generation, remove anonymous fallback

Anonymous users could generate images for free by hitting the endpoint
without auth. The authenticateUser() function silently fell back to
creating anonymous users who bypassed all credit checks.

Now returns 401 if no valid session or API key is provided. Cleaned up
all anonymous-specific code paths (isAnonymous checks, anonymous
reservation creation for unauthenticated users).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: add DB fallback when fal.ai HTML pricing parsers fail

The 8+ fal.ai pricing parsers scrape HTML with regexes. If fal changes
their page structure, the parsers throw and crash the entire fal catalog
refresh.

Now each model's parse is wrapped in try/catch. On failure, fall back to
last known active DB entries for that model. If no DB fallback exists,
log an error and return empty (other models still succeed).

This prevents one broken model page from taking down pricing for all
video models.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: hoist quotedVideoCost declaration to handle catch block reference

The partial charge logic in the catch block references quotedVideoCost
which was previously declared inside the try block, causing a TypeScript
error.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: correct cache variable name in evictExpiredCacheEntries

Biome autoformatter or manual edit corrupted 'externalCatalogCache' to
'third - partyCatalogCache' in our cache eviction patch, causing build
failure on production.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: trigger fresh Vercel build (bust cache)

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(oauth): support multiple Google connections per user

Drop the single-connection-per-(user, platform) constraint so each Milady
user can link several distinct Google accounts (e.g. personal + work
gmail) simultaneously. Uniqueness is still enforced at the (organization,
platform, platform_user_id) level so the same Google account cannot be
linked twice.

- 0067 migration drops platform_credentials_user_platform_idx
- schema: remove the matching uniqueIndex
- oauth2 storeConnection: stop revoking "other" connections for the same
  user+platform on every new OAuth completion; upsert by (org, platform,
  platform_user_id) only and keep other accounts intact
- milady-google-connector: add listManagedGoogleConnectorAccounts that
  returns every connected Google account per side
- new GET /api/v1/milady/google/accounts route
- google-connection.tsx: render every active connection with per-row
  disconnect and an "Add another Google account" button
- tests: resolver multi-account listing, disconnect-by-id isolation,
  migration asserts the user_platform_idx is dropped

* fix: actually replace third-partyCatalogCache with third-partyCatalogCache

Previous commits attempted to rename but the disk file still had a
hyphen in the identifier ("third-party-catalog-cache"), which TypeScript
parses as subtraction. This sed-based fix writes the actual replacement.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: remove approve-pr.yml workflow (security risk flagged in review)

The workflow allowed anyone with workflow_dispatch access to approve any
PR number via GITHUB_TOKEN, bypassing branch protection and normal review.
Additionally had a shell injection vulnerability via unquoted
${{ inputs.pr_number }} interpolation.

This was bundled into PR 458 but unrelated to the steward auth fix.
Reviewers flagged it as a critical supply-chain risk.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat: add device-bus, twilio voice, remote sessions, billing service

Bundles substantive cloud-side work that accumulated on the
multi-google-connections branch:

- Device-bus tables, schemas, and admin/users + remote API surfaces
- Twilio voice inbound-call schemas + adapter + route
- Remote sessions schema/repo + API
- Billing service package
- Agent loader/runtime cleanups (drop legacy agent-runtime.ts)
- DB schema and migration housekeeping
- UI tweaks across character builder, chat pages, and sidebar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: address PR 458 review feedback

1. Remove PRIVY_MIGRATION_TRIAGE.md - planning docs shouldn't be in repo
2. dashboard/layout.tsx now uses useSessionAuth() - eliminates the manual
   cookie check race condition and duplicated auth logic. The steward-authed
   cookie-based check ran only once on mount; useSessionAuth subscribes to
   storage events and steward-token-sync events for reactive updates.
3. useStewardAuth() no longer violates Rules of Hooks - reads
   StewardAuthContext directly via useContext instead of try/catching
   useAuth(). Same behavior, no hook rule violation.
4. Migration 0064 now uses IF NOT EXISTS on tables and indexes per
   CLAUDE.md migration rules (prevents re-run failures in prod).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): migrate signup-prompt-banner to Next router

Replaces usePrivy().login with router.push('/login?returnTo=...'),
matching the pattern used in sidebar-bottom-panel, user-menu, and
other already-migrated auth entry points.

One of the S-rated migrations from PRIVY_MIGRATION_TRIAGE.md.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix: tighten device bus google connection handling

* Clean up device bus lint issues

* fix(auth): recover from expired access token instead of logging out

The silent-logout bug: when an access token sat idle long enough to
fully expire (browsers throttle setInterval in background tabs, so a
15-min idle was enough), the auto-refresh loop had already given up
because it only ran when `secs > 0`. On top of that, syncToken would
delete the server cookie as soon as it saw an expired token, killing
any chance of server-side recovery.

This refactor:
- Drops the `secs > 0` gate in checkAndRefresh so we try to refresh
  even when the access token is fully expired (the refresh token is
  typically still valid for 30 days).
- Moves the "clear server cookie" decision to AFTER refresh fails
  instead of firing on every expired-token observation.
- Adds a visibilitychange listener so we immediately attempt a refresh
  when a tab becomes visible again (covers the 'came back from lunch'
  case where background setInterval was throttled).
- Runs an eager refresh check on mount (covers hard reload after idle).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(deps): bump @stwd/react to 0.6.6 for StewardAuthContext export

0.6.5 didn't re-export StewardAuthContext from the package index,
even though use-session-auth.ts imports it directly. Builds worked
before because of lockfile drift; Turbopack is stricter. 0.6.6 adds
the missing re-export.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): try refreshSession on login page before showing UI

Shadow reported still getting logged out of elizacloud. Traced the
bug: when the client-side refresh loop hasn't fired yet (e.g. came
back after server-rendered navigation with expired access cookie),
the middleware redirects to /login. The login page calls
`auth.getSession()` \u2014 which returns null if expired \u2014 and shows
the login UI even though a 30-day refresh token is still sitting in
localStorage.

Now the login page tries `auth.refreshSession()` before falling back
to the UI. If it succeeds, sets the cookie and redirects the user
back to where they were going. Fast-path for the "valid session"
case is preserved.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: snapshot worktree changes

* chore: snapshot cloud develop merge worktree

* chore: remove misleading deprecated labels from still-active code

auth-anonymous, Message.reasoning, isFinish fallback branches, and the
/api/mcp/stream 410 stub were all labeled "deprecated" but remain
actively in use. Reworded to describe what the code actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix cloud workflow failures

* fix steward login lint dependencies

* fix standalone biome root config

* feat: add MCP browser and search routes

* cloud: proxy lifeops schedule sync routes

* feat(auth): server-side Steward token refresh in edge proxy

Moves the refresh race out of client JS and into the edge proxy. When a
protected request arrives with an expired access token and a valid
refresh token cookie, the proxy calls Steward's /auth/refresh server-side,
rotates both cookies, and forwards the request with the new bearer so
downstream auth works on the same round trip.

- proxy.ts: refresh flow with structured [steward-auth] logging,
  transient-failure tolerance (5xx/network errors log but don't redirect),
  401 clears both cookies
- app/api/auth/steward-session/route.ts: sets both steward-token and
  steward-refresh-token httpOnly cookies, DELETE clears both
- packages/lib/providers/StewardProvider.tsx: syncs refresh token to the
  bridge alongside access token, drops visibilitychange handler (middleware
  handles return-from-idle now)
- app/login/steward-login-section.tsx: minor body-shape update for the
  new session bridge POST signature
- packages/tests/unit/steward-proxy-refresh.test.ts: coverage for
  success/401/5xx/network-error paths + same-request auth forwarding

Fixes the 15-30 min silent-logout bug.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore(fmt): biome format proxy.ts + tests

Auto-fix from biome check --write.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: bump @stwd/sdk + react + add wallet peer deps

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(login): add wallet login + GitHub OAuth to login page

Co-authored-by: wakesync <shadow@shad0w.xyz>

* feat(sync): handle wallet-only Steward sessions in syncUserFromSteward

Co-authored-by: wakesync <shadow@shad0w.xyz>

* test: cover wallet-only sync path

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(login): use @stwd/react/wallet subpath import + bump to 0.7.1

The previous workaround imported WalletLogin from a deep dist path because
0.7.0 had a packaging bug (referenced BackpackWalletAdapter which is no
longer in @solana/wallet-adapter-wallets). Fixed in 0.7.1.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore(fmt): biome format steward-session route

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore(fmt): biome format login files

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(tests): cast Mock<fetch> via unknown + annotate Array callbacks

TypeScript strict mode rejected Mock<() => Promise<Response>> as typeof
fetch without an intermediate unknown cast (preconnect is missing).
Fix test file to:
- cast all globalThis.fetch mocks via 'as unknown as typeof fetch'
- type Array.some/every/find callback params as string (previously implicit any)
- cast fetchMock.mock.calls[0] as unknown[] to avoid tuple length 0 error

Unit tests continue to pass (5/5).

Co-authored-by: wakesync <shadow@shad0w.xyz>

* ci: retrigger checks

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: checkpoint local changes

* chore(fmt): wrap long line in proxy refresh test

Co-authored-by: wakesync <shadow@shad0w.xyz>

* x updates

* cloud: execute lifeops x operations

* chore(deps): bump @stwd/react to 0.7.2 (wallet-login style fixes)

Fixes the 'Connect wallet' + 'Select Wallet' text wrap and the mismatched
EVM/Solana button sizing reported after yesterday's login UI ship.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: preserve session changes

* migrate app-auth/authorize to steward

swap privy for steward on the app-auth/authorize page and reuse
requireAuthOrApiKey on /connect and /session so both endpoints
accept privy or steward jwts through existing code paths.

- wrap authorize page in StewardAuthProvider
- replace usePrivy/useLogin with useAuth + StewardLogin
- drop unreachable "continue as" branch + unused AuthorizeFallback
- collapse connect/session routes onto requireAuthOrApiKey and
  nextJsonFromCaughtErrorWithHeaders for consistent cors errors

* fix(login): native Ethereum + Solana buttons matching Google/Discord style

Screenshot feedback: the @stwd/react WalletLogin component had a dev-tool
aesthetic (mono font, sharp corners, harsh black embedded card) clashing
with elizacloud's login card (Inter, soft corners, unified surfaces).
UX was also two-step: connect wallet, THEN click 'Sign in'.

Replaces with two native buttons (Ethereum / Solana) styled identical to
Google / Discord. One-click flow:
  1. Click button → opens RainbowKit modal (EVM) or Solana wallet modal
  2. Once wallet connects, auto-triggers SIWE / SIWS signature
  3. Success → redirect

Drops the <WalletLogin> + custom theme-var wrapper from the login page.
Keeps StewardWalletProviders for wagmi + Solana hooks context.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(db): repair _journal.json drift from migration tracking

Prod Neon DB was missing 10 tables (affiliate_codes, user_affiliates,
service_pricing, service_pricing_audit, org_rate_limit_overrides,
device_bus_devices, device_bus_intents, twilio_inbound_calls,
remote_sessions, credential_mappings in n8n_workflow schema) and 3
columns (referral_codes.parent_referral_id, referral_signups.app_owner_id,
referral_signups.creator_id), all silently broken for months.

Root cause: _journal.json was out of sync with migration files.

- 7 migration files had no journal entry (drizzle never ran them):
  0017_add_organization_encryption_keys
  0048_00..03_elite_rumiko_fujikawa_*
  0065_add_device_bus_tables
  0066_add_twilio_inbound_calls
- Duplicate idx=63 between 0067 and 0068 (data integrity bug)

Fixed journal:
- Added 7 missing entries with synthetic timestamps
- Re-sequenced idx so every entry has a unique monotonically-increasing idx
- All 72 migration files now represented in journal

Migrations were applied directly to prod via psql (all idempotent,
verified via dry-run transaction first). __drizzle_migrations table was
back-filled with 12 missing hash entries so future drizzle-kit migrate
runs see everything as applied.

Pre-existing drizzle-kit generate error about duplicate snapshot parent
(0064 referenced by both 0065 variants) is unrelated and not addressed
here; it's a generation-time issue, not a migration-time issue.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(auth): preempt steward session expiry with eager server+client refresh

Shadow reported being signed out after ~15min, which matches the access
token TTL exactly. Two gaps in our refresh story that made this happen:

1. Server middleware only triggered /auth/refresh when the access token
   was ALREADY expired. If the user's first navigation after a long idle
   coincided with the token expiring milliseconds ago, the refresh
   window was racy. Now we preemptively refresh whenever the token has
   less than 180s remaining on any protected-path request.

2. Client-side interval timer (checkAndRefresh every 60s, triggers when
   <120s left) is unreliable in background tabs. Chrome throttles
   setInterval to roughly 1 call per minute in inactive tabs and can
   suspend them entirely when memory-pressure hits. A user returning to
   an idle-for-15-min tab would find their token gone and the interval
   hadn't fired its check. Added visibilitychange + online event
   handlers that force an immediate check-and-refresh when the tab
   becomes visible or the network reconnects.

Together these close the window in which an expired token reaches any
server request handler. The server-side middleware is the real backstop
since it runs for every protected request regardless of tab state.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(ai-pricing): store gateway token rows under language/embedding

Image-generation models (e.g. gemini-*-image) were classified as product
family image for all pricing rows, including per-token input/output.
Chat billing resolves language:input, so lookups failed after catalog
refresh. Token and web_search entries now use embedding vs language
only; image-specific rows remain under image.

Made-with: Cursor

* fix(ai-pricing): resolve Anthropic snapshot ids to gateway catalog ids

Requests use dated API ids (e.g. anthropic/claude-sonnet-4-5-20250929) while
the gateway and OpenRouter catalogs list stable ids (anthropic/claude-sonnet-4.5).
Expand catalog lookup candidates by stripping snapshot dates and normalizing
version hyphens (4-5 -> 4.5).

Made-with: Cursor

* feat(ai-pricing): gateway model alias table for renamed ids

Adds GATEWAY_PRICING_MODEL_ALIASES (legacy id -> current catalog ids) with
automatic reverse lookup so new ids still match older persisted pricing rows.
Anthropic snapshot normalization remains; teams can append explicit Vercel
rename mappings as the gateway catalog changes.

Made-with: Cursor

* chore(ai-pricing): populate gateway model rename aliases from AI SDK history

Derives legacy -> current ids from vercel/ai gateway-language-model-settings
changes (#6828, #7249, #6544) and validates targets against the live
ai-gateway catalog. Notes approximate xAI grok-2* -> grok-3* successors.

Made-with: Cursor

* test(ai-pricing): gateway product family regression + refresh docs

- Document that catalog refresh deactivates all active rows for the
  snapshot source_kind before insert (no orphan active token rows with
  wrong product_family).
- Inline token product family logic; export buildGatewayPreparedEntries
  and GatewayCatalogModel for tests.
- Add unit tests: image-generation models keep language token rows and
  image generation rows; embedding models use embedding family.

Made-with: Cursor

* fix(ai-pricing): widen GATEWAY_PRICING_MODEL_ALIASES type for string index

as const + satisfies produced a closed key set so GATEWAY_PRICING_MODEL_ALIASES[canonicalModel]
failed typecheck; use a single Record assertion instead.

Made-with: Cursor

* fix: ai-pricing aliases, lint/types, and ambient stubs

AI pricing: resolve DB/live rows using the provider inferred from each catalog candidate (cross-provider gateway aliases). Batch active pricing lookups via listActiveEntriesForProviderModelPairs. Precompute GATEWAY_PRICING_LEGACY_IDS_BY_TARGET for reverse alias lookup, cap Anthropic suffix normalization iterations, and warn when pricing is matched via a non-canonical model id.

Tooling: exclude packages/types ambient .d.ts from Biome to avoid internal parser panics; repair provisioning-worker shebang for Biome parsing.

Types: add ambient declarations for @elizaos/billing, music-metadata, Steward, and optional wallet deps; align Eliza runtime/MCP, API routes, webhooks, and tests with current typings and Biome formatting.
Made-with: Cursor

* fix(auth): type steward login component

* feat(x): charge credits for cloud operations

* fix(types): cover steward and wallet auth helpers

* fix(db): allow historical duplicate migration prefixes

* fix(types): retry silent split typecheck kills

* fix(x): return json errors from cloud routes

* fix(types): bound split typecheck concurrency

* fix(x): preserve upstream error statuses

* fix(tests): align mcp registry status expectations

* fix(auth): send tenant_id (snake_case) to steward /auth/oauth authorize

The Steward authorize route reads the param as 'tenant_id' via
c.req.query('tenant_id'). We were sending camelCase 'tenantId', so the
server silently fell back to the user's personal tenant
('personal-<userId>') and issued refresh tokens scoped to that tenant.
Those tokens work for personal-tenant calls but not for elizacloud
endpoints, so any session that went through GitHub/Google/Discord OAuth
got knocked off elizacloud once the Privy path fell away.

DB evidence: Shadow's user has 4 personal-tenant refresh tokens
interleaved with elizacloud tokens, exactly at timestamps matching
OAuth login attempts.

Fix is one param rename. Server side will be patched to accept both
variants in a follow-up for robustness.

Co-authored-by: wakesync <shadow@shad0w.xyz>

* fix(x): complete managed x endpoints and tests

* fix(api): harden openapi server metadata

* test(oauth): align twitter connection ids

* fix(tests): stabilize integration server reuse

* docs: refresh llms model ids

* fix(db): make legacy cleanup migration idempotent

* fix(x): support oauth2 lifeops connections

* Fix Twitter OAuth role status

* Separate OAuth secrets by connection role

* fix(db): make legacy cleanup migration idempotent

Co-authored-by: wakesync <shadow@shad0w.xyz>

* test(auth): cover steward tenant_id authorize param

Co-authored-by: wakesync <shadow@shad0w.xyz>

* chore: bump typescript to ^6.0.0 across workspace

Made-with: Cursor

* refactor: rename OAuth API coverage and refresh UI copy

* fix(login): preserve query string on OAuth redirect_uri

returnTo (used by /auth/cli-login, app-authorize, etc.) must survive
the OAuth round-trip; otherwise users signing in from a deep-linked
page land on /dashboard instead of the page that redirected them to
/login.

Made-with: Cursor

* add market data

* fix: use live polymarket market ordering

* fix: sync install metadata for CI

* fix: unblock cloud CI workflows

* feat(cloud): Twitter OAuth2 client, Gmail subscription headers, Google connector

Add twitter-automation oauth2-client with route and unit tests; extend Milady
Google connector and Gmail subscription-headers API; refresh token handling
and Twitter connection UI.

Made-with: Cursor

* fix(security): set COOP same-origin-allow-popups on /app-auth/* routes

The OAuth popup-callback flow opens a provider popup, polls
window.closed to detect completion or cancellation, and then exchanges
the returned code/token. Default Cross-Origin-Opener-Policy of
'same-origin' (or any CORP-isolated default) makes that closed-check
throw, so the parent window never notices the popup finishing and the
user is stuck in a re-prompt loop.

'same-origin-allow-popups' keeps the page cross-origin-isolated for
its own resources but exempts popups it opened, which is the contract
the Steward + Privy SDKs are coded against.

Scoped to /app-auth/* so dashboard / app pages keep the stricter
default. No other change to the existing CSP/security header set.

* feat(app-credits): debit org balance instead of per-app pool

reworks AppCreditsService.checkBalance / deductCredits / reconcileCredits
to read and write organizations.credit_balance via creditsService instead
of the per-app app_credit_balances table. signed-in cloud users can now
spend their org balance on any monetized app without pre-purchasing a
per-app pool. app developers still earn the markup % via the existing
recordCreatorEarnings -> redeemableEarnings path.

messages route now passes estimatedBaseCost=0 so reconcile charges the
full actual cost as the diff (the anonymous reservation never debited
upfront, so there is nothing to refund).

verified against local cloud: chat call dropped org balance from
1000.000000 to 999.999744 (actual 0.000256), per-app pool unchanged,
and a "App reconciliation charge (eDad)" debit row landed in
credit_transactions.

* fix(app-auth): rebuild authorize page with explicit consent + truncate user menu

prior auth flow auto-redirected on isAuthenticated and bailed silently
when useAuth().user was null (session loaded but user record not yet
hydrated). result: signed-in users saw only a Cancel button with no way
to continue. fixed by switching to standard oauth consent ux:
- signed out: <StewardLogin> form + Cancel
- signed in:  "Signed in as <email>" + "Authorize <app>" + Cancel

also fetches the email from /api/v1/user when the steward jwt omits it,
so the consent label shows the real address instead of "your account".

user dropdown in the top-right was overflowing on long emails; added
truncate + min-w-0 + title for hover-to-see-full so it stays inside the
w-56 menu.

minor: dropped a redundant intermediate `result` var in
deductCredits — references orgDeduct.newBalance directly.

* refactor(authorize-content): tighten per 5-rule audit

- inline single-call-site redirectWithError helper into handleCancel
- drop "wide" prop from Frame (only ever passed one value)
- narrow AppHeader's appInfo to non-null via early-return guard;
  remove the impossible "?? "A"" / "?? "Application"" / "App logo"
  defensive fallbacks (AppInfo.name is required by the cloud schema)

no behaviour change. types tightened, dead defensive code removed.

* feat(cloud): image route hardening + GPT Image 2 + tighten role error

* wip: milady-plaid-connector skeleton

* wip: paypal + plaid milady route stubs and deps

* fix(app-auth): unblock local Steward in CSP + drop hydration-prone header

CSP previously only included loopback origins (localhost:3000/3200) when
NODE_ENV=development. Running `next start` against a local Steward
instance with NEXT_PUBLIC_STEWARD_AUTH_ENABLED=true sets NODE_ENV to
production, so the CSP stripped the Steward origin and the browser
refused @stwd/react's fetch to localhost:3200/auth/providers — the
StewardLogin form rendered blank. Widen the gate to also include local
loopback when STEWARD_AUTH_ENABLED is true.

Authorize page: dropped LandingHeader. The header renders different
markup on server vs client based on auth state ("Log in" vs "Dashboard"),
which threw a React hydration error and remounted the whole tree —
that prevented validateApp's effect from completing, leaving the page
stuck on "Verifying application...". Standard OAuth consent UIs
(Google, GitHub) keep this screen header-less for the same reason.

Also dropped a dead <video> tag pointing at an asset not in the repo
(was 404'ing in 60s+ on every page load) and inlined its 1-line
wrapper, replaced with a static gradient.

* feat(app-auth): conditional hero video, gradient fallback

Re-add the /videos/Hero* asset on the OAuth consent screen layered over
the existing gradient. Browsers fire onError on 404, which we use to
display:none the <video> element so the gradient underneath stays
visible. Net effect:

- prod (asset present): plays the hero loop, same as before
- local / self-hosted (asset missing): silently falls back to gradient

No conditional fetch or env check — the browser's own load behaviour
decides at runtime, so the same component works for both deploys.

* fix(app-auth): drop email lookup, drop dead video tag

Removed two non-essential pieces from the consent screen:

1. The email-fetch effect + accountEmail state. We were chasing
   `user.email` from useAuth, then falling back to a /api/v1/user
   round-trip when the Steward session didn't surface it. The label
   now just reads "Signed in" with the green check — same intent,
   no network dependency, no slow path on cloud cold-start.

2. The <video> hero element. Local dev doesn't ship the asset and
   the resulting 404 was adding noticeable load time. Solid gradient
   stays.

Net: -55 lines, simpler component, faster page.

* chore: checkpoint local workspace changes

* test: update cloud sdk client coverage

* build: add cloud sdk dist output

* fix: checkpoint cloud sdk http update

* build: update cloud sdk http sourcemap

* test: checkpoint cloud sdk live e2e update

* fix: checkpoint cloud sdk openapi auth updates

* chore: checkpoint cloud sdk source updates

* build: checkpoint cloud sdk dist updates

* docs: checkpoint cloud sdk readme update

* chore: checkpoint cloud connector route updates

* fix(app-auth): return to authorize page after login

* chore: refresh cloud lockfile

* chore(deps): bump uuid to ^14.0.0 (GHSA-w5hq-g745-h8pq)

uuid v3/v5/v6 missing buffer bounds check when external buf is provided.
v14 adds the same RangeError guard as v1/v4/v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: isolate twitter oauth route test

* chore: refresh lockfile after uuid bump

* chore: add cloud sdk route coverage audit

* feat: expose generated cloud public routes in sdk

* Use direct eliza package imports

* fix: preserve app auth return after login

* fix: stabilize cloud lint and plugin sql types

* feat(milady-google): list managed calendars (#472)

* feat(cloud): image route hardening + GPT Image 2 + tighten role error

* wip: milady-plaid-connector skeleton

* wip: paypal + plaid milady route stubs and deps

* chore: checkpoint cloud connector route updates

* chore: checkpoint local workspace changes

* test: update cloud sdk client coverage

* build: add cloud sdk dist output

* fix: checkpoint cloud sdk http update

* build: update cloud sdk http sourcemap

* test: checkpoint cloud sdk live e2e update

* fix: checkpoint cloud sdk openapi auth updates

* chore: checkpoint cloud sdk source updates

* build: checkpoint cloud sdk dist updates

* docs: checkpoint cloud sdk readme update

* fix(app-auth): return to authorize page after login

* chore: refresh cloud lockfile

* chore(deps): bump uuid to ^14.0.0 (GHSA-w5hq-g745-h8pq)

uuid v3/v5/v6 missing buffer bounds check when external buf is provided.
v14 adds the same RangeError guard as v1/v4/v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add cloud sdk route coverage audit

* feat: expose generated cloud public routes in sdk

* Use direct eliza package imports

* test: isolate twitter oauth route test

* chore: refresh lockfile after uuid bump

* fix: preserve app auth return after login

* fix: stabilize cloud lint and plugin sql types

* feat(milady-google): list managed calendars (#472)

* chore: biome formatter auto-fixes after develop rebase

* Format generated SDK dist

Made-with: Cursor

* fix(app-credits): also pass estimatedBaseCost=0 in chat/completions

The reconcile path expects estimatedBaseCost to reflect what was already
debited upfront. Both messages/route.ts and chat/completions/route.ts use
createAnonymousReservation() (no real upfront debit) for the app-credits
path, so both must report 0 to make reconcile charge the full actual cost.
messages/route.ts was fixed earlier in this PR; this brings chat/completions
in line so monetized apps don't under-charge users by the estimate amount.

* Use direct shared contract import

---------

Co-authored-by: Sol <sol@shad0w.xyz>
Co-authored-by: wakesync <shadow@shad0w.xyz>
Co-authored-by: hanzlamateen <hanzlamateen@live.com>
Co-authored-by: dexploarer <dexploarer@gmail.com>
Co-authored-by: Odilitime <janesmith@airmail.cc>
Co-authored-by: standujar <s.andujar@proton.me>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: RemilioNubilio <275382225+RemilioNubilio@users.noreply.github.com>
Co-authored-by: nubs <nubs@iqlabs.dev>
Co-authored-by: dutchiono <86275975+dutchiono@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants